Skip to content
GitRiverGitRiver
RU
Navigation

GitRiver CI — User Guide

Build pipelines: the workflow file, jobs and steps, dependencies, variables and secrets, cache, artifacts, runners

A GitRiver CI workflow is described by a YAML file in the repository, and its foundation is run: scripts. Ready-made actions (uses:) are supported partially, and the installation administrator restricts their external sources to a list of allowed domains — see “uses — reusable actions”.


Quick Start

Create the file .gitriver/workflows/ci.yml in the root of the repository:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    steps:
      - run: echo "Hello, GitRiver CI!"

When pushing to main or opening a pull request, a workflow with a single test job will run.


Workflow File Structure

name — workflow name

name: Build and Deploy

Shown in the interface. If not specified, the file name is used.

on — triggers

Define when the workflow runs.

push

on:
  push:
    branches: [main, develop, 'release/**']
    branches-ignore: ['feature/wip-*']
    tags: ['v*']
    tags-ignore: ['v*-rc*']
    paths: ['src/**', 'Cargo.toml']
    paths-ignore: ['docs/**', '*.md']
  • branches / branches-ignore — branch glob patterns
  • tags / tags-ignore — tag glob patterns
  • paths / paths-ignore — the workflow runs only if files matching the pattern changed
  • branches and branches-ignore are mutually exclusive (cannot use both)
  • If paths is specified, the workflow is skipped when no file matches

A push is any write to a branch, not just git push from the console: editing a file through the interface, a batch commit, and building a knowledge draft raise the same event and trigger the same workflows.

Path Globs

The rule is the same for all CI globs — branches, tags, paths/paths-ignore in on:, and paths in artifacts::

  • the pattern is matched against the entire path (for artifacts: — from the job’s working copy), not the file name: *.so matches lib.so in the root, but not target/release/lib.so;
  • * — any sequence of characters within a single segment, ? — exactly one character; neither crosses a /;
  • ** — a separate pattern segment: zero or more path segments. src/** matches both src itself and everything under it; **/*.rs matches both main.rs and src/main.rs; a/**/b matches both a/b and a/x/y/b;
  • a pattern without * or ? is an exact path. Character classes ([abc]) are not supported and are compared as literal characters;
  • in branches and tags, a pattern consisting of a single asterisk ('*') is read as “any value”, including names with a slash: branches: ['*'] matches all branches.

pull_request

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]
    paths: ['src/**']
  • types — which pull request events trigger the workflow (default: opened, synchronize, reopened)
  • branches — the pull request’s target branch (what it merges into)
  • paths — filter by changed files

The server produces three actions: opened — when the pull request is created, reopened — on reopening, synchronize — on every push to the source branch while the pull request is open. Editing the title and description doesn’t trigger a run; the target branch of an open pull request cannot be changed.

paths is computed from the diff of the whole pull request — from the branch point to its tip — not from a single push: the result doesn’t depend on which commit introduced the change.

schedule — cron schedule

Schedule accuracy. Cron expressions are checked once a minute, so the actual run may lag the specified time by up to several dozen seconds. A repeated match on the same branch within 120 seconds does not trigger a second build — a workflow with several close cron rules doesn’t double-run.

on:
  schedule:
    - cron: '0 2 * * *'      # Every day at 02:00 UTC
    - cron: '0 */6 * * *'    # Every 6 hours
  • Standard 5-field cron (minute hour day month day_of_week)
  • Runs on the default branch
  • Minimum interval: 15 minutes

The schedule has an author — whoever’s push landed the cron rule on the default branch. Before each run, the platform checks that this person still exists, is not disabled by external management (SCIM), and still holds the ci:trigger permission in the repository. Otherwise the schedule is paused: no build runs, and the reason is visible on the CI/CD tab (GET /api/v1/repos/{owner}/{name}/ci/schedules). This way, an employee’s departure doesn’t leave behind a working deployment mechanism: both pushes and manual runs are denied to such a person as well. In an archived repository, the schedule is paused for the same reason — writes to it are forbidden.

The pause lifts itself: the permission is re-checked at every scheduled run, and restored access brings the schedule back to work without intervention. If the author’s account no longer exists, the schedule is re-established — by changing the cron expression in the workflow: the new pair (file, cron) gets as its author whoever made that change.

If the push that introduced the schedule wasn’t made by a person (a CI job token or a deploy token), and for schedules with no recorded author, the repository owner is considered the author. Such a schedule stops when the owner is disabled by external management, and resumes as soon as the account is re-enabled or the repository is transferred to an active owner.

Manual Run (workflow_dispatch)

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deployment environment'
        required: true
        type: choice
        options: [staging, production]
        default: staging
      debug:
        description: 'Enable debugging'
        type: boolean
        default: false
      version:
        description: 'Version to deploy'
        type: string
      replicas:
        description: 'Number of replicas'
        type: number
        default: 3

Input types: string, boolean, choice, number.

Values are available as variables: $INPUT_ENVIRONMENT, $INPUT_DEBUG, etc.

Combining Triggers

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 2 * * *'
  workflow_dispatch:

The workflow runs on any of the listed events.


env — global environment variables

env:
  RUST_LOG: info
  CARGO_TERM_COLOR: always
  REGISTRY: registry.example.com

Available to all jobs and steps. Overridden at the job and step level.


concurrency — concurrency group

concurrency:
  group: deploy-$CI_COMMIT_BRANCH
  cancel-in-progress: true
  • group — group name (supports $VAR substitution)
  • cancel-in-progress: true — automatically cancels the previous run in the same group

A group promises one thing: no more than one pipeline runs in it at a time. cancel-in-progress chooses how that promise is kept:

cancel-in-progress What happens to the new run What happens to the running one
true starts immediately cancelled
false (default) waits in the group’s queue runs to completion

A waiting pipeline is created and visible in the list — marked “queued in group” — but doesn’t occupy runners. As soon as a slot frees up, it starts on its own; there’s no need to start it separately.

Only the last one can wait: if another push arrives while waiting, the previous waiting one is cancelled. This way an active branch doesn’t accumulate a row of already-stale builds.

While a pipeline waits for a slot:

  • manually running or retrying a job is refused — the job would bypass the group and occupy runners while another build is running in it;
  • restarting the server doesn’t drop the waiting pipeline: it will occupy the freed-up slot on its own;
  • the pipeline is dropped if the repository was archived or the workflow disappeared from the commit (the branch was rewritten) — there’s nothing left to wait for;
  • a fork pull request pipeline waiting for a maintainer’s approval doesn’t occupy a slot and goes through the queue only after approval.

The group applies within a repository: a same-named group in another repository is a different group, and the runs of two projects don’t cancel or wait for each other.

Interruptibility by ref works as usual even with a group declared: a job with interruptible: true will still be cancelled by a new push.

Example: pushing to main cancels the old, not-yet-finished deployment with a new one.

Short form:

concurrency: deploy-$CI_COMMIT_BRANCH

Equivalent to group: ..., cancel-in-progress: false — that is, with waiting in the queue rather than cancelling the running one.


jobs

A job is a unit of execution. Jobs run in parallel by default.

jobs:
  build:
    name: Build the project
    steps:
      - run: cargo build --release

  test:
    name: Tests
    needs: [build]
    steps:
      - run: cargo test

needs — dependencies between jobs

jobs:
  build:
    steps:
      - run: cargo build

  unit-tests:
    needs: [build]
    steps:
      - run: cargo test --lib

  integration-tests:
    needs: [build]
    steps:
      - run: cargo test --test '*'

  deploy:
    needs: [unit-tests, integration-tests]
    steps:
      - run: ./deploy.sh
  • Jobs listed in needs must complete successfully before this one starts
  • Without needs, a job starts immediately (in parallel with others)
  • Circular dependencies are a file validation error

A dependency can also be written as a mapping:

deploy:
  needs:
    - job: tests
    - job: lint
      optional: true

optional: true means the dependency may not run: if lint is skipped by its own if: condition, deploy still starts — after tests. A failed optional dependency stops the dependent job the same way a failed required one does. To keep its failure from blocking dependents, declare it with allow-failure: true.

if — job run condition

jobs:
  deploy:
    if: $CI_COMMIT_BRANCH == "main"
    steps:
      - run: ./deploy.sh

  notify-on-fail:
    if: failure()
    needs: [deploy]
    steps:
      - run: curl -X POST $SLACK_WEBHOOK

Supported expressions:

Expression Description
$VAR == "value" String comparison
$VAR != "value" Inequality
$VAR =~ /pattern/ Regular expression match
$VAR True if defined and not empty
!expr Logical NOT
expr1 && expr2 Logical AND
expr1 || expr2 Logical OR
(expr) Grouping
success() All previous jobs succeeded (default)
failure() At least one previous job failed
always() Always run (even on cancellation)
cancelled() The pipeline was cancelled

The condition is evaluated by the server — before the job reaches a runner, and identically for the built-in runner and an external runner. A job with a false condition gets the “skipped” state and is never handed to a runner at all, so runs-on has no effect on what if: does: deploy with a branch condition won’t be deployed from a different branch on any runner.

Skipped is not failed. To the jobs that depend on it, a skipped job means “did not run”:

  • a job with the default condition (success()) is skipped too, unless the dependency is declared with optional: true;
  • failure() is true only if a dependency failed. In the example above, notify-on-fail doesn’t run on branches other than main, where deploy is skipped: nothing failed;
  • a cancelled dependency also stops a job with the default condition, but it doesn’t count as a failure.

For a dependent job to run regardless of its dependencies’ outcome, declare it with if: always().

image — Docker image

jobs:
  build:
    image: rust:1.82-slim
    steps:
      - run: cargo build --release

The job runs inside the specified container via docker run. If not specified, the script runs directly in the runner’s environment.

The script runs in the container as root, so files it creates get root ownership and permissions per the image’s umask. Right after the script, the runner takes back ownership of the working copy — using the same job image, in a short-lived container. Thanks to this, on an image with umask 0077 (for example, redis:7), the job’s artifacts, reports, and outputs are collected as usual. If restoring permissions fails, a warning appears in the job log. A runner older than the server doesn’t perform this step — keep runners updated together with the server.

services — service containers

jobs:
  test:
    image: rust:1.82
    services:
      - image: postgres:16
        alias: db
        env:
          POSTGRES_DB: test
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
      - image: redis:7
        alias: cache
    env:
      DATABASE_URL: postgres://test:test@db:5432/test
      REDIS_URL: redis://cache:6379
    steps:
      - run: cargo test
  • alias — hostname for accessing the service from the job’s container
  • env — environment variables for the service
  • If alias isn’t specified, the image name before : is used (postgres, redis)
  • Services run in a Docker network and are reachable by alias as a hostname
  • Requires image: — without a Docker image, the job’s services don’t start; the reason is written to the job log, not silently skipped

The directive works the same way on the built-in runner and on an external runner: the name the script uses to reach the service is the same on both; the network and containers are started by whoever runs the job, on its own Docker. A runner older than the server won’t parse the services field and will run the job without the services — keep runners updated together with the server.

timeout — job deadline

jobs:
  build:
    timeout: 30m     # 30 minutes
    steps:
      - run: cargo build --release

Formats: 30s, 10m, 1h, 1h30m. Default: 1h. Maximum: 6h.

A job that misses its deadline is stopped and marked failed (not cancelled: cancellation is a human decision). The last line of its log is “Job stopped after exceeding its deadline”, and the retry rule retry: when: [stuck_or_timeout_failure] is designed exactly for this outcome. after_script still runs in this case — it has its own deadline (ci_after_script_timeout_secs).

allow-failure — allowed failure

jobs:
  lint:
    allow-failure: true
    steps:
      - run: cargo clippy -- -D warnings

If the job fails, the pipeline isn’t considered failed. The job is marked with a warning in the interface.

retry — automatic retry on failure

jobs:
  test:
    retry: 2           # Retry up to 2 times on any failure
    steps:
      - run: cargo test

Extended form:

jobs:
  test:
    retry:
      max: 2
      when: [script_failure, stuck_or_timeout_failure]
    steps:
      - run: cargo test
  • max — maximum number of retries (0–2)
  • when — which errors to retry on:
    • always — any error (default)
    • script_failure — nonzero exit code
    • stuck_or_timeout_failure — deadline exceeded

Cancellation outranks retry:: a cancelled job is never retried regardless of when — cancellation is a user decision, not a failure worth replaying.

Between attempts, a separator is written to the log: ──── Retry 1/2 ────.

A job with image: gets, right before the separator, a line reading Removing previous attempt's container: <name>: the interrupted attempt’s container is removed so that the next attempt can start.

The policy also applies to jobs on an external runner. There the retry is server-side: the job is reset and re-queued for runners — a different runner is free to pick up the new attempt. The runner reports not finishing the job in time separately from a script failure, so when: [stuck_or_timeout_failure] distinguishes them there too. A runner older than the server reports every failure as script_failure.

interruptible — interruptibility on a new push

jobs:
  test:
    interruptible: true
    steps:
      - run: cargo test

If interruptible: true and a new push arrives on the same ref, the running job is automatically cancelled in favor of the new pipeline. Useful for tests, useless for deployments.

Difference from concurrency:

  • concurrency — keeps no more than one running pipeline in the group (by group name, within its own repository): with cancel-in-progress: true it cancels the running one, without it — queues the new one
  • interruptible — cancels by ref (branch/tag), without naming a group explicitly

runs-on — runner label

A job with runs-on other than default is run by an external runner (gitriver-runner) with matching labels — see “Cloning the Repository as an External Runner”. If no runners are configured on the installation at all, the job runs on the built-in runner.

The rule is the same for every way of starting a run: the initial pipeline run, a job retry, and a manual run all hand a job with runs-on to the external runner the same way — a retry’s result equals the first run’s result.

If the built-in runner is disabled by the ci_local_executor_enabled setting (an installation without Docker), labels aren’t considered at all: the entire queue goes to external runners, including runs-on: default and jobs without runs-on. A job without labels may be picked up by a runner with any labels, and runs-on: default by a runner that has declared the default label.

There is no “run on the server” fallback in this mode: if no runners are configured or the queue is unavailable, the job fails immediately, and the reason is written to its log (“built-in runner disabled, no runner available”). A runner that’s registered but offline is a different case: the job waits for it in the queue up to ci_runner_max_wait_secs.

runs-on: default              # Built-in runner
runs-on: [linux, docker]      # A runner with both labels

steps — steps within a job

Each step is one command or a block of commands.

steps:
  - name: Install dependencies
    run: |
      apt-get update
      apt-get install -y protobuf-compiler

  - name: Build
    run: cargo build --release 2>&1
    env:
      RUSTFLAGS: '-C target-cpu=native'

  - name: Notify
    if: failure()
    run: echo "Build failed"

  - name: Cleanup
    if: always()
    run: rm -rf tmp/

Step Fields

Field Description
name Display name (optional)
id Step identifier (optional). Nothing references it: step outputs aren’t collected
run A command or a multiline script
uses A reusable action instead of a command — see “uses — reusable actions”. Incompatible with run in the same step
with Action input parameters (only for uses) — arrive in it as INPUT_*
if Run condition. if: always() — the step runs regardless of the previous steps’ outcome, if: failure() — only after they failed; such steps go into after_script — see below
env Environment variables (only for this step)
timeout Step deadline (only for run)
continue-on-error true — the step doesn’t fail the job on error
working-directory Working directory (only for run)
shell Shell for the run step: sh (default) or bash

Steps aren’t isolated from each other. They run in a single shell session: a cd, an exported variable, or a file created by one step are visible to the following ones without explicit passing — just like the working directory, they’re shared across all of the job’s steps.

The exception is a step with shell: bash, timeout, or working-directory: it runs as a separate process, and its cd and export don’t reach the following steps (the files it creates do).

Multiline Scripts

- run: |
    echo "Step 1"
    echo "Step 2"
    if [ -f config.yml ]; then
      echo "Config found"
    fi

Runs as a single sh shell script with set -e (stop on the first error).

uses — reusable actions

A step with uses: runs a reusable action — code that doesn’t live in this workflow file. The field is incompatible with run: in the same step: a step is either a command or an action.

steps:
  - name: Check formatting
    uses: myorg/actions/fmt@v1
    with:
      check-only: 'true'
Four Source Forms
uses: Where the action comes from @ref
docker://image:tag The image runs directly, no action.yml needed not needed
./path, ../path A working-copy directory — path from the step’s current directory, i.e. from its root if earlier steps haven’t done a cd not needed
owner/repo@ref, owner/repo/subfolder@ref A repository on this installation required
host/owner/repo@ref, host/owner/repo/subfolder@ref An external git server, cloned over HTTPS optional (defaults to main)

The forms are distinguished by a dot in the first path segment — that’s the hostname. A dot in a tag (myorg/action@v1.2.3) or in a repository name (myorg/my.action@v1) doesn’t change the source: such an action is still taken from this same installation.

@ref is a branch or tag name; a commit SHA isn’t accepted here.

The part after owner/repo (and after host/owner/repo) is the action’s subfolder inside the repository, not part of its address: the repository is cloned, and action.yml is read from the subfolder.

An action on this installation is cloned using the job’s account (CI_JOB_TOKEN), so a private repository is accessible — within the job’s permissions (see “What CI_JOB_TOKEN Can Do”). An external source is cloned anonymously: a private external repository won’t be cloned.

The action’s clone is cached by the “address + ref” pair in the /tmp/gitriver-actions directory (overridden by the GITRIVER_ACTION_CACHE variable). The cache lives wherever the job’s script runs: for a job without image: — on the runner’s machine, for a job with image: — in its container, i.e. for exactly one job. As long as the clone is in the cache, it isn’t refreshed — the runner won’t pick up movement of a moving ref (@main) — point at an immutable tag.

Restricting External Sources

External actions are subject to the allowed-domains list set by the installation administrator — allowed_external_action_domains (see the installation guide):

Setting What’s allowed
not set any external source
[] — empty list none: any external action is forbidden
["git.example.com"] the whole domain
["git.example.com/actions"] only this owner
["git.example.com/actions/checkout"] only this repository

The comparison is case-insensitive and matches on segment boundaries: git.example.com doesn’t match git.example.com.evil.test, and git.example.com/actions doesn’t match git.example.com/actions-evil.

A forbidden action rejects the entire workflow file: no pipeline is created for it at all — neither green nor red. The author sees the reason on the commit — see “Rejected Workflow File”.

How an Action Runs

docker://image runs immediately: a container of that image with the working copy mounted at /workspace (which also serves as the working directory). For the other forms, the platform reads action.yml (or action.yaml) in the action’s directory and looks at runs.using:

runs.using What the platform does What’s needed in the job’s environment
composite Runs the action’s run: steps python3 (parses action.yml) and bash
docker Takes runs.image: Dockerfile — builds it on the spot, docker://… or an image name — runs it as-is. The working copy is mounted at /github/workspace docker
node12, node16, node20, node* Runs runs.main in the action’s directory node

Any other using (as well as a missing action.yml) is a step error. A composite action has its own shell, unlike a job — see “Shell”.

The platform restores the working copy’s permissions after an action’s container itself, and it also removes the container itself — see “File Permissions After Containers” and “What Happens to Containers After the Job”.

with — input parameters

Each pair from with: arrives in the action as the INPUT_<NAME> environment variable: the name is uppercased, and - is replaced with _. A name that isn’t a valid environment identifier is silently skipped. A Docker action receives the same INPUT_*.

- uses: myorg/actions/fmt@v1
  with:
    check-only: 'true'      # inside the action: $INPUT_CHECK_ONLY

The export lives until the end of the job, not the end of the step: INPUT_* from one action stays in the environment and reaches the next one — local, cloned, or an action with using: docker (a uses: docker://… step is the exception — it only receives its own). This follows from the shared session: if an action behaves differently depending on whether a parameter is set, set it explicitly.

What’s Missing Compared to GitHub Actions
Not supported What this means in practice
${{ … }} expressions Not evaluated anywhere, including in with: — the value goes to the action literally. In run: the shell does the substitution ($VAR)
inputs: from action.yml default: isn’t substituted, required: isn’t checked: the action gets exactly what’s set in with:
Action outputs $GITHUB_OUTPUT, ::set-output, and steps.<id>.outputs.* aren’t read. Data is passed between jobs via $CI_OUTPUT (see “outputs”)
Docker action args:, entrypoint: Ignored: the container runs with its own image’s ENTRYPOINT/CMD
runs.env, runs.pre, runs.post Not run
Composite action nesting Only its run: steps are taken; uses:, shell:, if:, and working-directory inside the action are ignored
timeout, working-directory, shell on a step Only work on a run: step. Ignored on a uses: step; if, env, and continue-on-error work on both
Step isolation An action runs in the same session as all of the job’s run: steps: same variables, same directory
GitHub’s action catalog (Marketplace) Exactly four sources, listed above; the installation doesn’t have its own action catalog either

Shell

- run: echo $HOME
  shell: bash        # Default: sh

Available shells: sh, bash. With shell: bash, the step runs as a separate bash process.

Both runners use one shell — sh with set -e. It’s present in any image, including alpine and distroless, where bash isn’t installed. The rule applies the same way to the job’s script and to after_script, on the host and inside the image’s container.

A step that runs as a separate process (shell: bash, timeout) doesn’t inherit set -e: such a step’s multiline script doesn’t stop on the first error, and the step’s outcome is taken from its last command. If you need it to stop on the first error, start such a script with set -e.

pipefail is not enabled: POSIX sh doesn’t have this feature, so a pipeline’s outcome is taken from its LAST command —

- run: tests | tee report.log     # a failing `tests` won't fail the job

If you need pipeline strictness, enable it yourself, in your own step: you know which shell is in your image, the runner doesn’t.

- run: set -o pipefail; tests | tee report.log
  shell: bash

A composite action (uses: with using: composite) is an exception: its steps run via bash -eo pipefail. Such a step needs bash in the job’s image, even though its own script doesn’t need it.

after_script: Where It Runs

Steps with if: always() and if: failure() run as a separate part of the job — after_script: after all other steps, in the order they’re declared. A step with always() runs regardless of the script’s outcome, a step with failure() — only if the script failed (including when it was stopped by its deadline). after_script sees the script’s outcome in the CI_JOB_STATUS variable: success or failed. It can’t be set to a value of your own — the platform sets it on top of the job’s variables.

after_script runs in the same place as the job’s script. The rule is the same for the built-in runner and for an external runner:

Job Where after_script runs
With image: In a container of the same image
Without image: Via the runner’s shell on the host — same as the script itself

A cancelled job doesn’t run after_script — on either runner: cancellation means “stop,” not “run one more step.” A job stopped by its deadline does run after_script.

after_script has no deadline directive of its own: by this point the job’s deadline (timeout:) has already been spent by its script. The installation sets the limit — GITRIVER_CI_AFTER_SCRIPT_TIMEOUT_SECS (default 300s), and it’s the same for both runners. When it expires, the work is stopped: after_script never leaves processes behind.

What this means for a job with image::

  • image commands (npm, mvn, cargo from it) are available in after_script — they might not exist on the host at all, or exist at a different version;
  • $CI_WORKSPACE, $CI_PROJECT_DIR, and $CI_OUTPUT point inside the container — the same place as during the script;
  • the job’s services are still up, and services: aliases still resolve;
  • the after_script container is a separate container of the same image. Only the working copy is inherited: packages installed by the script outside it, files outside the working directory, and background processes of the job’s container don’t survive into after_script;
  • a uses: step with if: always() runs in the container too — meaning a Docker action needs Docker inside the job’s image, exactly like the same step without if: always().
build:
  image: node:20
  steps:
    - run: npm ci && npm test
    # npm — the image's command; it might not exist on the runner's host
    - run: npm run report -- --out report.txt
      if: always()

File Permissions After Containers

The container runs as root, and the working copy is mounted into it with write access. Some images have umask = 0077 (for example, redis:7), and a file created in the container ends up -rw------- root:root: a runner that runs as a regular user can’t open such a file. The symptom is always the same — the job succeeds, but there are no artifacts, reports, or $CI_OUTPUT.

The platform restores its access to the entire working copy on its own before anything reads it from the host: collecting artifacts, reports, outputs, and cache. For a job with image:, this happens after after_script — it runs in a container of the same image and also leaves files owned by root; for a job without image: — right after the script, and once more after after_script if a container was started in it.

Container Who restores permissions
The job’s container (image:) The platform — using the job’s image
An action’s container (uses: docker://…, an action with using: docker, including image: Dockerfile) The platform — using that container’s image
A container started by the script itself (docker run, docker build in run:) The workflow’s author

The boundary runs along whoever started the container: the platform knows nothing about containers the script starts itself — neither the image, nor which of its files the job still needs. The platform keeps the list of its own containers’ images in a service file, .ci-action-images, in the working copy’s root; it doesn’t end up in artifacts or cache.

What Happens to Containers After the Job

The platform removes them along the same boundary — before restoring permissions, collecting artifacts, and removing the working copy:

Container How it’s identified Who removes it
The job’s container (image:) and the after_script container By name The platform
An action’s container (uses:) By the gitriver-ci-job=$CI_JOB_ID label — it has no name The platform
A container started by the script itself (docker run in run:) No way to identify it The workflow’s author

This matters for deadlines and cancellation: they kill the job’s processes, but not containers — the docker run client is killed, but the daemon’s container keeps running. The platform removes its own on its own; a container started manually by the job’s script outlives it, unless the author ran it with --rm or removed it in after_script.

Cleanup of an interrupted job that started containers can take up to a few seconds: the platform waits for containers the daemon hasn’t finished creating yet.

If your step starts a container itself, restore permissions in that same step — one of two ways:

steps:
  # 1. The container runs as your user — works if it doesn't need root
  - run: docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/w" -w /w alpine sh -c 'echo ok > out.txt'

  # 2. The container needs root — restore ownership right after it
  - run: |
      docker run --rm -v "$PWD:/w" -w /w alpine sh -c 'umask 077; make build'
      docker run --rm -v "$PWD:/w" --entrypoint sh alpine -c 'chown -Rh "$(stat -c "%u:%g" /w)" /w'

When restoring permissions fails (for example, a daemon with userns-remap), the platform doesn’t stay silent: a line appears in the job log stating that the working copy’s permissions weren’t restored — and it explains the empty artifacts further down the log.


strategy — matrix builds

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu, alpine]
        rust: ['1.80', '1.82', stable]
      fail-fast: false
      max-parallel: 4
    image: rust:$MATRIX_RUST
    steps:
      - run: cargo test
  • Produces a job for each combination (2 × 3 = 6 jobs)
  • Values are available via $MATRIX_<KEY> (uppercase)
  • fail-fast: true (default) — cancels the rest on the first failure
  • max-parallel — how many matrix jobs run at the same time. The limit counts jobs, not server resources: it applies equally to built-in-runner jobs and to jobs with runs-on (external runners), as well as to retrying or manually running a single matrix job. A value of 0 is forbidden — the workflow is rejected at parse time

Each matrix job’s name is built as <name> (<values>) and serves as the name for its log files, outputs, and artifacts. That’s why matrix values must not contain /, \, .., or a null byte: a workflow with a value like alpine/git is rejected with an error at run time. The full name (including the suffix) is limited to 255 characters.

include / exclude

strategy:
  matrix:
    os: [ubuntu, alpine]
    rust: [stable, nightly]
    include:
      - os: ubuntu
        rust: nightly
        features: experimental     # Additional variable
    exclude:
      - os: alpine
        rust: nightly              # Don't test nightly on alpine
  • include — adds combinations or extends existing ones
  • exclude — excludes specific combinations

artifacts — job artifacts

jobs:
  build:
    steps:
      - run: cargo build --release
    artifacts:
      paths:
        - target/release/myapp
        - target/release/*.so
      expire-in: 7d
  • paths — paths and patterns of what to save. They follow the common path glob rule: from the job’s working copy, * doesn’t cross /, ** matches zero or more segments
  • expire-in — retention period (default: 30d)
  • Formats: 1h, 7d, 30d, 1y, never
  • Artifacts are available to jobs that depend via needs. A job without needs gets the artifacts of jobs in its own pipeline that already saved them (except its own: a re-run starts from a clean working copy) — order of execution is set only by needs, so you can only count on such a set by declaring the dependency explicitly
  • Downloaded through the interface
  • A directory is saved whole: whether named by path (paths: [public]) or matched by a glob (pub*, public/**) — together with nested files and empty subdirectories. Symbolic links are not followed into: the link itself ends up in the archive, but link/* won’t match any files — otherwise a link to a directory outside the working copy would leak someone else’s files into the artifact. A path without a glob does pass through a link (link/index.html): its author named it explicitly
  • The entire working copy (paths: ['.'], paths: ['**']) is saved by its contents: every file and directory at the root as its own entry, directories still whole
  • The runner’s service entries don’t end up in the archive — neither matched by a glob, nor named by path: .git-credentials (the job’s account with the active CI_JOB_TOKEN), .docker (the container registry account after docker login), .ci-output (the $CI_OUTPUT file), and .ci-action-images (images of containers the platform started from the job’s script — see File Permissions After Containers). The runner itself puts them in the working copy’s root. They’re hidden from git by .git/info/exclude, so git add -A in the job’s script doesn’t add them
  • .git doesn’t end up in the archive — neither matched by a glob, nor named by path: the working copy’s .git/config holds the job’s LFS access (lfs.url and an Authorization: Basic header with the job’s token), and paths: ['.'] would carry it into the archive as plain text. The rule works the other way too: on restore, .git entries from the archive are skipped — the working copy’s .git always comes from that job’s own clone. A job that saved a repository with history (paths: ['.'] for a deployment) will get an archive without .git: any needed history should go into an artifact by an explicit path (for example, git bundle create history.bundle --all)
  • The contents of the archive for the same paths are identical on the built-in runner and on an external runner
  • The number of files matched by a glob is unlimited: paths: ['**/*.js'] in a project with node_modules is collected in full, no matter how many files the glob matches
  • A non-UTF-8 file name that matches a glob (or sits at the working copy’s root, when the whole thing is saved) fails the job with a log line, and the file itself doesn’t go into the archive — it needs to be renamed
  • A file name with a backslash (\) doesn’t go into the archive and fails the job with the same line — it needs to be renamed: different tar builds read such a name differently, and it can’t be packed reliably
  • An external runner receives dependencies’ artifacts from the server with its own token and only within its job’s scope (its needs, or without them — its own pipeline); the set is named by the server, and every outcome is logged to the job

An incomplete archive fails the job. If a declared file couldn’t be read (no access permission, an inaccessible directory it was searched in), the job finishes in the “failed” state, and a line appears in its log: “Collecting artifacts: could not read some files — the archive is incomplete,” with the reason from the packer. The rule is the same for the built-in runner and for an external runner. The part of the archive that was collected is saved — it shows what’s missing. Harmless warnings don’t fail the job and are only noted with a log line: a file that was being written to during packing, and a path from paths that wasn’t found in the working copy (such a path is simply skipped — same as a glob that matched nothing). If nothing was found at all, a log line “Collecting artifacts: none of the specified paths were found in the working copy” is added, and the job stays successful.

Reports (artifacts: reports:) don’t fail the job. A declared report (junit, coverage_report, dotenv) may not exist — for example, a step failed before it could be created — so a read failure only produces a log line: “JUnit report ‘report.xml’ not read: no such file in the job’s working copy” (or the OS reason, if the file exists but is inaccessible). The line is the same on the built-in runner and an external runner: it shows why the “Tests” tab is empty and dotenv variables didn’t reach dependent jobs. The $CI_OUTPUT file is an exception: its path isn’t declared, the script itself creates the file, so a line is only produced by an inaccessible file, not by its absence.

expire-in: expired artifacts are checked and automatically deleted at least once an hour — so actual deletion may lag expire-in by that long. The server sets the deadline — the same for an archive from the built-in runner and from an external one; if expire-in isn’t set or the value isn’t recognized, the 30d default applies. The archive’s size counts against the max_ci_artifacts quota and is freed from it on deletion. The limit applies on BOTH runners: an archive that doesn’t fit into the remaining quota isn’t saved, and the job fails — the artifacts were declared, and dependent jobs are waiting for them.


cache — caching between runs

jobs:
  build:
    steps:
      - run: cargo build --release
    cache:
      key: cargo-$CI_COMMIT_BRANCH
      paths:
        - target/
        - ~/.cargo/registry/
      policy: pull-push
  • key — cache key (supports $VAR substitution)
  • paths — directories to cache. Unlike artifacts: paths:, these are exact paths, not globs: directories are cached whole, and there’s nothing to expand here. So a path with * or ? doesn’t end up in the cache at all — as if it didn’t exist
  • The entire working copy (paths: ['.']) is cached by its contents: every file and directory at the root as its own entry, directories still whole
  • The runner’s service entries don’t end up in the cache archive — the same rule and the same list as with artifacts: .git-credentials, .docker, .ci-output, .ci-action-images
  • .git doesn’t end up in the cache and isn’t restored from it — the same rule as with artifacts
  • A name with a backslash (\) doesn’t end up in the cache — the same rule as with artifacts; a line about this appears in the job log, but the job isn’t failed for an incomplete cache
  • policy:
    • pull-push (default) — read and write
    • pull — read only (for jobs that shouldn’t update the cache)
    • push — write only

When restoring the cache, file permissions are carried over from the archive (rwx bits), so that cargo build scripts and tools installed via cache stay executable. Special bits (setuid/setgid/sticky) and other-write are never carried over from the archive. The same rule applies to dependent jobs’ artifacts; file owner and group are never assigned to job files from the archive under any circumstances.

File modification time (mtime) is carried over from the archive too — for both the cache and dependent jobs’ artifacts. So incremental build tools (cargo, make, ninja) correctly see which files from the cache are stale relative to the commit’s sources, and rebuild them.

The time from the archive is validated:

  • a value from the future, by the runner’s clock, isn’t applied — the file stays with its unpack-time timestamp;
  • zero time (a corrupted header) isn’t applied;
  • a time older than the runner’s clock is carried over as-is;
  • directories get their timestamp on the final pass, after their contents are written;
  • a hard link doesn’t get a timestamp set: it shares an inode with its target, which already has its own time;
  • a symbolic link gets the timestamp set on the link itself, not on the file it points to;
  • if setting the time failed (the filesystem doesn’t store it, unpacking isn’t running as the owner), the job doesn’t fail — same as with an incomplete cache; a line with the count of such entries appears in the server’s or the runner’s log.

A consequence worth knowing when setting up caching: the clone lays down working-copy files fresh on every run, with a “now” timestamp. So the repository’s own crates are always rebuilt regardless, and the cache only speeds up what comes from the archive with its own full timestamp — dependencies (~/.cargo, target/ for them, node_modules).

The cache is stored on the server, in the CI data directory (ci_data_path), and on restore it’s unpacked into the job’s working directory. The cache is shared across all pipelines and branches of the repository: only the key separates them, which is why $CI_COMMIT_REF_SLUG is put into it. Another repository’s cache is unreachable even with a matching key.

A fork pull request pipeline writes its cache separately and can’t replace the repository’s shared cache — the one protected-branch builds receive. It can still read the shared cache: its own is searched first, then the shared one — so fork pull request builds don’t start from an empty cache. This requires nothing from the workflow’s author: key and paths are the same.

The cache is saved only after a successful job: the cache of a failed build would corrupt subsequent runs too.

The cache counts against a quota and is evicted by deadline. The total size of the archives is counted in the max_ci_cache category of the repository’s owner — separately from artifacts. When the limit is exhausted, the cache isn’t saved, and the job stays successful — a line “Saving cache: the archive was not saved on the server. Quota exceeded…” goes into its log. The previous archive under the same key stays intact. Replacing an archive under an existing key is counted by the GROWTH in size, not by the new archive’s full size: a cache that once filled the limit keeps updating, and a replacement that doesn’t increase what’s occupied (a new archive the same size or smaller) goes through even at an exhausted quota.

An archive that hasn’t been accessed for longer than ci_cache_retention_days (14 days by default) is removed by background maintenance. The period is counted from the last ACCESS — a write or a hand-off to a job — so the cache of a build that keeps updating is never evicted, and the key of a branch that’s gone gets removed on its own. Zero in this setting disables deadline-based eviction: consumption is then bounded only by the quota.

Unlike artifacts, an incomplete cache doesn’t fail the job — it only speeds up the next run. But if the packer couldn’t read some files, a line appears in the job’s log: “Saving cache: could not read some files…”, explaining why the next build will go without part of the cache. paths entries that aren’t in the working copy are simply skipped.

The directive works the same way on the built-in runner and on an external runner: the runner gets the key with variables already substituted and exchanges the archive with the server, and each step writes a line to the job log — cache restored, not found by key, or saved. A runner older than the server may not know about cache exchange: it then runs cache: as a no-op, with no error in the log. Keep runners updated together with the server.


environment — deployment environment

jobs:
  deploy:
    environment:
      name: production
      url: https://app.example.com
    steps:
      - run: ./deploy.sh

The job is linked to the environment: it receives that environment’s variables (see “Variable Levels”), and the list of environments with their latest state is available in the repository settings (Settings → Environments) and via the API GET /api/v1/repos/{owner}/{name}/ci/environments.


Job Log

The job log is built the same way on the built-in and on an external runner: the header, sections, and final line are the same on both, and the executor line in the header names who picked up the job — a “built-in executor” note for the built-in one, the runner’s name for an external one.

The log is written in the installation’s language (the language setting, see the installation guide), not in the reader’s language. An external runner has no language setting of its own — the language comes to it from the server together with the job. The language is chosen ONCE per job: changing the setting mid-build doesn’t produce a log in two languages, and the retry separator and the line about the server writing the job off use the same language as the rest of the log. A manual retry starts the log anew — in the installation’s language at the moment of the retry. The output of the script’s own commands isn’t translated: the job’s programs write it.

With language = "en" the log looks like this:

Running on GitRiver CI
Executor: built-in executor
Pipeline: #01a097ac · Build · refs/heads/main
Commit: 7f081dca — fix key parsing

Preparing environment
Image: rust:1.82
Services: postgres:15
Getting source code
$ git clone …
Restoring artifacts
Artifacts of job build restored
Restoring cache
Key: cargo-linux-9f2c
Cache restored
Starting services
Service postgres:15 is reachable as postgres
Running script
…script output…
After script
…after_script output…
Saving artifacts
Collecting artifacts: archive saved, 12345 bytes
Saving cache
Key: cargo-linux-9f2c
Saving cache: archive saved, 98765 bytes
Job succeeded

A section appears only when it has something to say: without image: and services there is no “Preparing environment” section, without cache: — no cache sections.

The last line names the OUTCOME, and it also determines the job’s icon in the interface:

Log line Outcome
Job succeeded success
Job failed (exit N) failure
Job failed: artifacts were collected incompletely failure
Job stopped after exceeding its deadline failure
Job cancelled cancelled

A job the server itself wrote off (the runner went silent or never picked up the job within ci_runner_max_wait_secs) is also explained by a log line, not left blank.


outputs — passing data between jobs

Jobs can pass data through the $CI_OUTPUT file:

jobs:
  prepare:
    steps:
      - run: |
          VERSION=$(cat VERSION)
          echo "version=$VERSION" >> $CI_OUTPUT
          echo "should_deploy=true" >> $CI_OUTPUT

  deploy:
    needs: [prepare]
    if: $NEEDS_PREPARE_OUTPUTS_SHOULD_DEPLOY == "true"
    steps:
      - run: echo "Deploying version $NEEDS_PREPARE_OUTPUTS_VERSION"

How it works:

  1. A job writes key=value pairs to the $CI_OUTPUT file (one per line)
  2. After the job finishes, its outputs are saved on the server
  3. A dependent job gets the values via $NEEDS_<JOB>_OUTPUTS_<KEY> variables
    • The job name and key are uppercased
    • Hyphens in names are replaced with _
  4. The server substitutes the variables — the same way for built-in-runner jobs and external-runner ones, and before the job’s if: is resolved: a condition on a dependency’s outputs (example above) works on both runners. Variables from a dependency’s artifacts.reports.dotenv are inherited the same way.

Both runners collect outputs: an external runner sends the contents of its own $CI_OUTPUT to the server, and parsing is always done by the server — the rules are the same for both. A runner older than the server may not set the $CI_OUTPUT variable or send the file: dependent jobs then get an empty substitution, so keep runners updated together with the server.

Example with several dependencies:

jobs:
  detect:
    steps:
      - run: |
          echo "backend=true" >> $CI_OUTPUT
          echo "frontend=false" >> $CI_OUTPUT

  build:
    steps:
      - run: |
          echo "artifact_path=dist/app.tar.gz" >> $CI_OUTPUT

  deploy:
    needs: [detect, build]
    if: $NEEDS_DETECT_OUTPUTS_BACKEND == "true"
    steps:
      - run: echo "Artifact: $NEEDS_BUILD_OUTPUTS_ARTIFACT_PATH"

Environment Variables

Precedence (lowest to highest)

  1. Installation-, group-, repository-, and environment-level variables (“CI/CD variables” screens)
  2. Global env: from the workflow file
  3. Job-level env:
  4. Run input: INPUT_* from workflow_dispatch and CI_MERGE_REQUEST_*
  5. Predefined CI_* variables
  6. Step-level env: — applies within the script and only for its own step

The workflow file outranks settings, not the other way around: a variable set in settings can’t replace what’s set in the file — including matrix values and the step’s script text. The CI_, GITRIVER_, MATRIX_, NEEDS_, __GR_ namespaces are closed to users — such a variable can’t be set at any level.

Run input outranks the file: the pull request number and manual run inputs are set by the run itself, and env: doesn’t override them.

A practical rule follows from this: if a value is set both in settings and in env:, the job gets the env: value. If you need the setting to win — remove the name from the file’s env:.

Predefined Variables

Variable Description Example
CI Always true true
CI_PIPELINE_ID Pipeline ID 550e8400-...
CI_PIPELINE_SOURCE Run source (web — manual run) push, pull_request, schedule, web
CI_COMMIT_SHA Full commit SHA a1b2c3d4...
CI_COMMIT_SHORT_SHA Short SHA a1b2c3d
CI_COMMIT_BRANCH Branch (not set for a tag) main
CI_COMMIT_TAG Tag (not set for a branch) v1.0.0
CI_COMMIT_REF_NAME Full ref name — branch or tag refs/heads/main or refs/tags/v1.0.0
CI_COMMIT_REF_SLUG Branch or tag name, safe for addresses and file names feature-my-branch
CI_COMMIT_MESSAGE Commit message fix: bug #123
CI_SERVER_URL Installation address https://git.example.com
CI_REPOSITORY_NAME Repository name myapp
CI_REPOSITORY_OWNER Owner myteam
CI_REPOSITORY_FULL_NAME Owner and name myteam/myapp
CI_REPOSITORY_URL Repository address https://git.example.com/myteam/myapp
CI_JOB_ID Current job’s ID 550e8400-...
CI_JOB_NAME Job name build
CI_JOB_TOKEN The job’s token — see “What CI_JOB_TOKEN Can Do” eyJ...
CI_REGISTRY Container registry address registry.example.com
CI_REGISTRY_IMAGE The repository’s image name in the registry registry.example.com/myteam/myapp
CI_REGISTRY_USER Registry username gitriver-ci
CI_REGISTRY_PASSWORD Registry token eyJ...
CI_WORKSPACE Working copy root /builds
CI_PROJECT_DIR Same as CI_WORKSPACE /builds
CI_WORKSPACE_HOST The same working copy, at the path the docker daemon sees it — for docker run -v from the job’s script /var/lib/gitriver/…/repo
CI_OUTPUT Outputs file, in the working copy’s root /builds/.ci-output
CI_CGROUP_PARENT CI resource slice: the cgroup the runner puts all of the job’s containers into. Empty means there’s no slice (see “CI Resource Slice and Image Builds”) gitriver.slice
DOCKER_CONFIG Docker credentials directory — its own for each job /builds/.docker

Working-copy paths depend on where the job runs, and hardcoding them is wrong. For a job with image:, the working copy is mounted into the container, and $CI_WORKSPACE points inside it; for a job without image: — to a host directory. The mount point itself differs between the two runners (/builds for the built-in one, /workspace for an external runner), which is why the path is handed over via a variable: a workflow written through $CI_PROJECT_DIR runs the same on both, while one written through /builds works on only one.

Hand your own container the working copy via $CI_WORKSPACE_HOST, not $CI_WORKSPACE. The daemon socket is exposed to jobs, and the script can start its own container — but the daemon looks paths up in the HOST filesystem. When the GitRiver server itself runs in a container, $CI_WORKSPACE points inside its own namespace: no such path exists on the host, docker silently creates an empty directory at it, and the container gets an empty working directory instead of the repository — the failure shows up from the inside, as a “no such file” line.

- name: Package with a third-party image
  run: |
    docker run --rm -v "$CI_WORKSPACE_HOST:/work" -w /work \
      registry.example.com/tools/nfpm:latest pkg --config packaging/nfpm.yaml --packager deb

Outside a container (an external runner on a machine, a server installed from a package), the variable equals $CI_WORKSPACE — you can always write through it.

Into $DOCKER_CONFIG, the runner does a docker login ahead of time to the installation’s container registry, on the repository’s author’s behalf: docker push $CI_REGISTRY_IMAGE works with no manual login. The directory is its own for every job and lives in its working copy — the credentials don’t stay on the machine after the job and don’t end up in the artifact archive, in the cache, or in git status.

For workflow_dispatch

Inputs are available as $INPUT_<NAME> (uppercase):

on:
  workflow_dispatch:
    inputs:
      target:
        type: string
# In steps: $INPUT_TARGET

For matrix

Matrix values are available as $MATRIX_<KEY> (uppercase):

strategy:
  matrix:
    node: [18, 20]
# In steps: $MATRIX_NODE

For pull_request

Variable Description
CI_MERGE_REQUEST_IID Pull request number
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME Source branch
CI_MERGE_REQUEST_TARGET_BRANCH_NAME Target branch
CI_MERGE_REQUEST_TITLE Pull request title

For Job Outputs

Dependencies’ outputs are available as $NEEDS_<JOB>_OUTPUTS_<KEY> (uppercase):

# If job "build" wrote "version=1.0" to $CI_OUTPUT,
# then the dependent job has this variable available:
$NEEDS_BUILD_OUTPUTS_VERSION  # → "1.0"

Repository Variables

Configured in Settings → CI/CD variables.

Field Description
Name Name (A-Z_0-9, without the CI_, GITRIVER_, MATRIX_, NEEDS_, __GR_ prefixes — reserved for predefined variables)
Value Value
Masked Hide in logs (the value is replaced with [MASKED])
Protected Give only to protected-branch pipelines. A pipeline for a tag does NOT get such a variable: GitRiver has no tag protection, and the “protected ref” flag is always false for a tag

Masked variables: the value is filtered out of every job log line, replaced with [MASKED]. Common encodings of the value (base64, hex, URL encoding) are masked too — in case a script prints the secret encoded.

A workflow file’s env: overwrites a repository variable — see “Precedence” above.

A masked variable’s value is a single string at least 8 characters long. A short value isn’t hidden by masking (it’s guessable), and it also ruins the log: a secret 1 would turn every “1” in the output into [MASKED]. A multiline value isn’t caught by line-by-line masking and would leak in full.

Variable Levels

Besides the repository, variables are set at three more levels. Each next level overwrites the previous one by name:

Level Where it’s set Who gets it
Installation Administration → CI/CD variables (admin) every pipeline on the installation
Group Group page → CI/CD variables (maintainer and above) the group’s repositories and its subgroups
Repository Settings → CI/CD variables the repository’s pipelines
Environment Settings → Environments → environment row → CI/CD variables only jobs with that environment:

The environment level is addressed by the job, not the pipeline: a job with environment: production gets the production environment’s variables, while a sibling job in the same pipeline without environment: — doesn’t. A job with an environment that has no variables set for it gets the other levels’ set unchanged.

deploy:
  environment: production   # gets the production environment's variables
  steps:
    - run: ./deploy.sh
build:
  steps:                    # no environment — won't see production's variables
    - run: make

The Masked and Protected rules, the trust filter (fork pull request, protected ref), and log masking work the same way at every level.

Who Gets Secrets

A job’s variable set is filtered by the trust context, which is fixed when the pipeline is created and isn’t recomputed afterward:

Pipeline Regular Masked Protected CI_JOB_TOKEN
Protected branch yes yes yes yes
Regular branch yes yes no yes
Tag yes yes no yes
Fork pull request yes no no no

The “Tag” row isn’t a table mistake: protection in GitRiver is set only on branches, so no tag is ever considered protected. Hence the rule for releases: variables a tag pipeline needs (registry credentials, a publish token) can’t be marked Protected — they won’t arrive, and the release job will fail on checking its own secrets. Masking them (Masked) is still needed: masking doesn’t depend on the tag.

CI_JOB_TOKEN (and its equal, CI_REGISTRY_PASSWORD) is live access: it writes to the repository’s container registry and is used as the job’s git account. The job’s script sees it in its environment, so it isn’t given to a fork pull request pipeline — otherwise the request’s author would get write access. Fork jobs still run: nothing stops them from building and checking the code.

The rule is the same for the built-in and an external runner. A job retry, a manual run, and a pipeline restart use the original pipeline’s trust — retrying a fork pull request job never gets the parent repository’s secrets.

Permission to Run Someone Else’s Code

Anyone who can write to their own fork can open a fork pull request — no rights on the base repository are needed for that. But checks occupy runners, so code from outside has a second barrier, besides secrets:

A pull request pipeline whose author lacks write access to the base repository is created, but not run. It’s visible in the pipeline list and on the pull request page, marked “awaiting approval”; its jobs are held. Any contributor with write access can allow the run — with the “Approve run” button on the pipeline page, or by calling:

POST /api/v1/repos/{owner}/{name}/pipelines/{id}/approve

Approval is one-time and applies to one pipeline: the next push to the pull request’s branch creates a new one, which also needs approval. Trust is not recomputed on approval — it’s taken from the pipeline, so secrets stay closed even after clicking it, even if the author has since gained access.

A fork pull request opened by a member of the base repository doesn’t wait for approval: the barrier is on foreign code, not on being a fork as such. Secrets are still not given to such a pipeline — the code comes from a different repository.

What CI_JOB_TOKEN Can Do

The token works only within its own pipeline’s repository — both in git and in the container registry. A job reads and writes its own repository; it has no access to any other repository on the installation, including public ones (403). The scope is the same for the built-in runner and an external runner.

A push via the token follows the same branch protection rules as a regular one: push and deletion restrictions, force-push blocks, and commit-signature requirements all apply. The one thing that doesn’t apply is the push allow list — the token isn’t tied to a user. So a branch a job shouldn’t write to needs to be closed off by a protection rule, not by relying on the token’s scope.

The token’s lifetime is set by ci_job_token_ttl_secs (default 8 hours). Until it expires, it’s accepted even after the job finishes, so a long lifetime on an installation with untrusted participants is best shortened.

CI_JOB_TOKEN and CI_REGISTRY_PASSWORD are predefined names: the value under them always comes from the server. They can’t be set as a repository variable or via env: in the workflow — such a value is stripped, not substituted.

Secrets on an External Runner

An external runner receives secrets together with the job, when it picks it up, and together with them — the list of masked-variable names. The runner strips their values from logs before sending to the server, and the server strips them again on receiving the log.

A runner is confined to its scope (installation, group, or repository), so a repository’s secrets only go to a runner that repository is accessible to. Hence a consequence for the administrator: an installation-scoped runner picks up jobs of any repository and gets their secrets — register such runners only on machines you trust; for third-party machines, set up a repository- or group-scoped runner. A group runner with the “serves subgroups” flag picks up jobs of its entire subtree — meaning it also gets subgroups’ secrets and runs their builds on its own machine. The machine’s owner turns the flag on; it’s off by default.

CI_JOB_TOKEN is masked in external-runner logs the same way as user secrets.

Cloning the Repository as an External Runner

The built-in runner takes code from the server’s disk, while an external runner clones the repository over HTTP, so the server hands it separate credentials just for cloning, together with the job:

  • read-only and only its own job’s repository — push and writes to the container registry are forbidden with this token (403), unlike CI_JOB_TOKEN;
  • also given to fork pull request jobs: without it, a private repository can’t be cloned, and the token gives no rights beyond reading exactly the code the job is running anyway;
  • never end up in the job script’s environment, in the working copy’s .git/config, or in the process argument list. The value is masked in logs.

Lifetime: 1 hour — cloning starts right after the job is received.

The job runs on its pipeline’s commit. The clone is shallow (--depth=1), and if the branch has moved on while the job sat in the queue, the needed commit is fetched separately by SHA. If it can’t be fetched, the job fails — it never runs on foreign code.

Git and LFS in an External Runner’s Working Copy

The working copy is prepared the same way as on the built-in runner.

git push and git fetch from the job’s script work with no setup. The runner sets up git credentials from CI_JOB_TOKEN for the job — there’s no need to build a URL like https://user:$CI_JOB_TOKEN@server/... by hand, git push origin is enough. The token is kept in a separate credentials file, not in the remote repository’s address, so it never appears in .git/config or in git remote -v’s output. A fork pull request job doesn’t get CI_JOB_TOKEN (see the table above) — push from it is impossible.

LFS files arrive as content, not as pointers. The runner sets up this installation’s LFS address and a token for it before checkout — otherwise the job would build pointer files instead of content, with not a single error in the log. The token is read-only for LFS objects of its own job’s repository and is masked in logs. Unlike CI_JOB_TOKEN, it’s kept in .git/config (lfs.url and an Authorization: Basic header) — which is why .git doesn’t end up in artifact or cache archives, even under paths: ['.']. Same for the built-in runner. After checkout the runner calls git lfs pull; if git-lfs isn’t installed on the runner’s machine, the job continues, with a warning going into the log. This operation’s limit is --lfs-timeout (GITRIVER_RUNNER_LFS_TIMEOUT, default 600 seconds).

Working-copy paths and the container registry are the same as on the built-in runner. The runner hands the script $CI_WORKSPACE, $CI_PROJECT_DIR, $CI_WORKSPACE_HOST, $CI_OUTPUT, and $DOCKER_CONFIG, and logs into the installation’s registry ahead of time, so a workflow written for the built-in runner carries over to an external runner unmodified. The docker credentials directory is its own for every job, inside its working copy: a docker login done by the script itself doesn’t stay in the runner user’s config either, and doesn’t reach the next job.

Installation Without a Built-in Runner

The ci_local_executor_enabled = false (GITRIVER_CI_LOCAL_EXECUTOR) setting disables the built-in runner: the server doesn’t run jobs, it doesn’t need Docker, and the entire queue goes to external runners. How to set up such an installation is covered in the installation guide.

What changes for workflow authors:

  • Labels aren’t considered. Every job needs a runner, including runs-on: default and a job with no runs-on at all.
  • A runner needs the default label. A job without runs-on is picked up by any runner, while runs-on: default — only by a runner that has declared the default label. If no runner has that label, runs-on: default jobs wait for a runner up to ci_runner_max_wait_secs (an hour by default) and then fail. The admin panel warns about this on the runners page, and the setup wizard warns on first launch.
  • There’s no “run on the server” fallback. If no runners are configured or the queue is unavailable, the job fails immediately, and the reason is written to its log.
  • cache:, artifacts:, and needs: work as usual. An external runner restores and saves the cache between runs through the server, and receives dependencies’ artifacts from it too; both are still stored on the server, under ci_data_path (see cache). The only condition is that the runner isn’t older than the server: a runner that doesn’t know about cache exchange runs cache: as a silent no-op, and one that doesn’t know about artifact exchange gets a 401 in its log and builds without dependencies’ files.

Multiple Workflow Files

.gitriver/
  workflows/
    ci.yml         # Tests on every push
    release.yml    # Release when a tag is created
    nightly.yml    # Nightly build on a schedule
    deploy.yml     # Deployment (manual run)

Each file is a standalone workflow with its own trigger events. On a push, GitRiver checks all files and runs the ones whose on: matches the event.

Rejected Workflow File

A file that couldn’t be parsed or built into a pipeline drops out of the run: no pipeline is created for it. This doesn’t affect the other files — a rejection is always per-file.

The author sees the reason on the commit: the platform posts a commit status in the error state, with the context gitriver-ci/<file name> and the reason as the text. It’s visible in the commit’s tooltip and in the pull request’s checks.

The status is tied to the commit and clears itself as soon as the same file on the same commit is accepted — for example, an administrator extended the allowed-domains list, and the next run (scheduled or manual) passed acceptance. A fix in a new commit gives an even cleaner state — a new commit has its own checks.

The rejection goes into the commit’s overall status, so the “CI must pass” branch protection rule won’t let such a file through: a pull request with an unparseable workflow stops at this check, rather than merging as if there were no checks at all.

There are four kinds of causes:

What’s wrong Example text
YAML doesn’t parse invalid workflow YAML: …
the file parses but contains an error circular dependency in needs: job 'build'
the file has a key the platform doesn’t know job 'build': unknown key 'runs_on'; did you mean 'runs-on'?
an action is forbidden by the domain list the external action 'evil.test/o/r' is forbidden by policy: the domain/owner 'evil.test' is not on the allow list (allowed_external_action_domains)

The trail is the same on every run path — push, pull request, schedule, manual run, and restart. A manual run and a restart, besides the status, also return the same reason directly in the API response (code 400): there’s no need to go to the commit page for it.

Long reasons in the status description are truncated to 200 characters — the full text stays in the server log.


Examples

Rust Project — Build and Release

# .gitriver/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
    paths-ignore: ['docs/**', '*.md']
  pull_request:

env:
  CARGO_TERM_COLOR: always

jobs:
  check:
    image: rust:1.82
    steps:
      - run: cargo check --all-targets
    cache:
      key: cargo-check
      paths: [target/, ~/.cargo/registry/]

  test:
    needs: [check]
    image: rust:1.82
    retry: 2
    services:
      - image: postgres:16
        alias: db
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
    env:
      DATABASE_URL: postgres://postgres:test@db:5432/test
    steps:
      - run: cargo test --all
    cache:
      key: cargo-test
      paths: [target/]

  clippy:
    image: rust:1.82
    allow-failure: true
    steps:
      - run: cargo clippy -- -D warnings
    cache:
      key: cargo-clippy
      paths: [target/]
      policy: pull

  fmt:
    image: rust:1.82
    steps:
      - run: cargo fmt -- --check
# .gitriver/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  build:
    image: rust:1.82
    steps:
      - run: cargo build --release
    artifacts:
      paths: [target/release/myapp]

  docker:
    needs: [build]
    steps:
      - run: |
          docker build -t $CI_REGISTRY/$CI_REPOSITORY_OWNER/$CI_REPOSITORY_NAME:$CI_COMMIT_TAG .
          docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
          docker push $CI_REGISTRY/$CI_REPOSITORY_OWNER/$CI_REPOSITORY_NAME:$CI_COMMIT_TAG

Node.js — Matrix Tests

name: Node.js CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    strategy:
      matrix:
        node: [18, 20, 22]
      fail-fast: false
    image: node:$MATRIX_NODE
    retry: 1
    steps:
      - run: npm ci
      - run: npm test
    cache:
      key: npm-$MATRIX_NODE
      paths: [node_modules/]

Deployment with Manual Confirmation

name: Deploy

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment'
        type: choice
        options: [staging, production]
        required: true
      skip_tests:
        description: 'Skip tests'
        type: boolean
        default: false

concurrency:
  group: deploy-$INPUT_ENVIRONMENT
  cancel-in-progress: false

jobs:
  test:
    if: $INPUT_SKIP_TESTS != "true"
    image: rust:1.82
    steps:
      - run: cargo test

  # Without tests, deployment starts right away; with tests, only after they pass
  deploy:
    needs:
      - job: test
        optional: true
    environment:
      name: $INPUT_ENVIRONMENT
      url: https://$INPUT_ENVIRONMENT.example.com
    steps:
      - run: ./scripts/deploy.sh $INPUT_ENVIRONMENT

Monorepo — Conditional Jobs via outputs

name: Monorepo CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  detect-changes:
    steps:
      - run: |
          # Check which paths changed via git diff
          if git diff --name-only HEAD~1 | grep -q '^backend/'; then
            echo "backend=true" >> $CI_OUTPUT
          else
            echo "backend=false" >> $CI_OUTPUT
          fi
          if git diff --name-only HEAD~1 | grep -q '^frontend/'; then
            echo "frontend=true" >> $CI_OUTPUT
          else
            echo "frontend=false" >> $CI_OUTPUT
          fi

  backend-test:
    needs: [detect-changes]
    if: $NEEDS_DETECT_CHANGES_OUTPUTS_BACKEND == "true"
    image: rust:1.82
    steps:
      - run: cd backend && cargo test

  frontend-test:
    needs: [detect-changes]
    if: $NEEDS_DETECT_CHANGES_OUTPUTS_FRONTEND == "true"
    image: node:20
    steps:
      - run: cd frontend && npm test

Pipeline with Retry and Failure Notification

name: CI with notifications

on:
  push:
    branches: [main]

jobs:
  test:
    image: rust:1.82
    interruptible: true
    retry:
      max: 2
      when: [script_failure]
    steps:
      - run: cargo test

  notify:
    needs: [test]
    if: failure()
    allow-failure: true
    steps:
      - run: |
          curl -X POST "$SLACK_WEBHOOK" \
            -H "Content-Type: application/json" \
            -d "{\"text\": \"CI failed on $CI_COMMIT_BRANCH ($CI_COMMIT_SHORT_SHA)\"}"

Migrating from GitLab CI

GitRiver CI workflows are taken only from the .gitriver/workflows/ directory. The server doesn’t read .gitlab-ci.yml or .gitriver-ci.yml files: they need to be rewritten in this guide’s format — following the table below.

Format Mapping

GitLab CI GitRiver CI
stages: + stage: needs: (explicit dependencies)
script: steps: [{run: ...}]
before_script: First step in the list
after_script: Last step with if: always() (runs in the image: container, as in GitLab)
when: on_failure if: failure() — on a job or on a step
variables: env:
rules: [{if:}] if: at the job level
rules: [{changes:}] on: push: paths:
only/except on: + if:
extends: None (YAML anchors if needed)
include: Separate workflow files
when: manual workflow_dispatch or if:
retry: retry: (kept)
allow_failure: allow-failure:
interruptible: interruptible: or concurrency: cancel-in-progress
services: services: (kept)
artifacts: artifacts: (kept)
cache: cache: (kept)
environment: environment: (kept)

Job Resource Limits

Built-in-runner jobs — with image: and without it — run under the same limits. Values are set by the installation administrator (see the installation guide); the defaults are:

Resource Value Job with image: Job without image:
Memory 2g (ci_docker_memory) docker --memory the job’s cgroup memory.max
Swap same as memory Docker default (--memory-swap = 2×memory) the job’s cgroup memory.swap.max
Processes and threads 512 (ci_job_pids_limit) docker --pids-limit the job’s cgroup pids.max
CPU 2 (ci_docker_cpus) docker --cpus not limited
Open files 1024 image default the script’s ulimit -n
Log size 100 MB — —
Run time 1 hour, maximum 6 hours (timeout) — —

The memory limit counts occupied memory, not address space. Runtimes that reserve tens of gigabytes of address space (JVM, Go, Rust with jemalloc, sanitizers) work fine under it: only what the job has actually occupied counts.

The process-count limit belongs to the job. It doesn’t depend on the server’s load or on how many jobs are running at once: each counts only its own processes and threads.

On exhausting the limit, the job fails, and the reason is written to its log as a separate line — with the limit’s value and the name of the setting that raises it:

The job hit its process limit: pids.max=512, rejected launches — 12.
Raise the limit with the ci_job_pids_limit setting.

The slice explains itself in the job log. The slice’s limit isn’t set on the job: hitting it gets the job Killed with code 137 — indistinguishable from its own build failure. That’s why the runner names the cause with a separate line:

The CI resource slice hit its memory limit: processes killed by the kernel — 1.
The slice is shared by all CI jobs, so a neighbouring job may have been killed as well.
Raise the limit with: systemctl set-property gitriver.slice MemoryMax=…
The job waited for CPU for 12 s: the CI resource slice hit its quota. The slice is shared
by all CI jobs — neighbouring jobs may have waited as well. Raise the quota with:
systemctl set-property gitriver.slice CPUQuota=…

Both lines are counted for the JOB’S OWN time, not the slice’s whole lifetime, and both say the slice is shared: the cause may lie not in the job itself but in the machine being busy.

A job without image: has its limits imposed by cgroup v2. If it’s unavailable to the server (cgroup v1, no delegation), the job runs without memory and process limits — the server logs this on the first job. A job with image:’s limits don’t depend on this: Docker imposes them.

External-runner jobs aren’t bound by these settings: resources are set by the runner’s own machine.

CI Resource Slice and Image Builds

The limits from the table above belong to a SINGLE job — and so they don’t count its entire consumption. A docker build step inside a job isn’t run by the job’s container, but by the BuildKit daemon: a separate container the docker daemon creates next to the job’s container, not inside it. Neither --memory nor --cpus from the job apply to it.

That’s why, on top of the job’s limits, there’s a CI resource slice — a shared cgroup that everything the runner itself starts falls into: the job’s container, after_script, services: services, action containers (uses:), working-copy cleanup service containers, and the runner itself together with jobs without image:. The BuildKit image builder doesn’t fall into the slice — more on that below. The slice’s name is visible to the job in the CI_CGROUP_PARENT variable; what bounds the slice is set by the installation administrator (see the installation guide).

Set your own image builder’s limits yourself. A builder started by the workflow doesn’t fall into the slice and can’t: the docker daemon creates it at the script’s request, next to the job’s container. The --driver-opt cgroup-parent=… option on docker buildx create passes validation, but never reaches the builder’s container — it stays in system.slice. What works are the limits of the BUILDER ITSELF:

- run: |
    docker buildx create --name builder-$CI_JOB_ID --use \
      --driver-opt memory=6g --driver-opt cpu-quota=300000
    docker buildx build --push -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .

cpu-quota is set in microseconds per a 100,000μs period: 300000 is three cores. Without these two options, the build is unlimited. The platform doesn’t set up a builder on the workflow author’s behalf.

A docker build step without buildx (the default builder) is run by the docker daemon itself, and its steps land in system.slice — neither in the slice, nor under the job’s limits, nor even under the docker service’s limits: they aren’t children of docker.service. They can only be limited by the daemon’s own default — the cgroup-parent key in /etc/docker/daemon.json — and that affects EVERY container on the machine, not just CI’s.

So it’s more reliable to give image builds buildx with their own builder and its own limits — or to move them to a dedicated external-runner machine.

The CI_CGROUP_PARENT variable is still useful where the script starts a container itself: docker run --cgroup-parent "$CI_CGROUP_PARENT" … puts it into the shared slice (this is exactly how the platform runs uses: actions).


Limits

  • Maximum 20 workflow files per repository
  • Maximum 50 jobs per workflow
  • Maximum 100 steps per job
  • Maximum 10 levels of needs nesting
  • Matrix: maximum 256 combinations
  • Job deadline: 1 hour by default, maximum 6 hours
  • Schedule: minimum 15 minutes between runs
  • Workflow file size: maximum 1 MB
  • paths / paths-ignore: maximum 100 globs

Interface

CI/CD Tab in the Repository

  • Pipeline run list — with filters by state and source
  • Each run shows: state, ref (branch/tag), commit, source, time, duration
  • Pagination
  • “Run pipeline” button

Pipeline Run Page

  • Jobs are grouped by stages
  • Each job’s state and duration (updated live for running ones)
  • Matrix jobs are grouped: test (node: 18), test (node: 20), …
  • Clicking a job opens its live log
  • Buttons: “Cancel”, “Restart”, “Restart failed”, “Run” (for manual jobs), “Restart” on an individual job
  • Job dependency graph (a “Stages / DAG” toggle when needs is present)
  • Pipeline states update live

Manual Run

  • “Run” button on the CI/CD tab
  • Choose a workflow and fill in inputs (fields by type: text, checkbox, dropdown, number)
  • Custom environment variables
  • Run and jump to the run

Badges

https://git.example.com/owner/repo/badge.svg
https://git.example.com/owner/repo/badge.svg?branch=develop

An SVG badge in the shields.io style, showing the latest pipeline’s current state.


Implementation Status

Fully Implemented

  • Auto-run on push according to each workflow file’s on: conditions
  • Filtering by paths/paths-ignore, branches/tags
  • Pull request trigger
  • Schedule (cron) — checked once a minute, a repeat match on the same branch within 120 seconds doesn’t trigger a second build
  • Commit checks — write and read via the API, the commit’s overall status, taken into account in pull request merge checks
  • Manual run (workflow_dispatch) with inputs: string, boolean, choice, number
  • Dependencies between jobs (needs), including optional ones (optional)
  • Evaluating if: conditions — comparisons, regular expressions, functions, logical operators
  • Matrix (strategy: matrix) with include/exclude, fail-fast, max-parallel
  • Concurrency groups (concurrency) with cancel-in-progress
  • Interruptible jobs (interruptible) — automatic cancellation on a new push to the same ref
  • Running in Docker via image:
  • Service containers (services) with alias and env
  • Artifacts — saving and downloading as an archive, by path globs
  • Cache (cache) — saving and restoring by key, policy: pull/push/pull-push
  • Retry (retry) — automatic, with max and when conditions
  • Allowed failure (allow-failure)
  • Manual jobs (run via API and interface)
  • Passing data between jobs ($CI_OUTPUT and $NEEDS_<JOB>_OUTPUTS_<KEY>)
  • Repository variables, including log-masked ones (Masked)
  • Live logs and pipeline states
  • Multiple workflow files
  • SVG state badges
  • Cancelling and re-running a pipeline and individual jobs
  • Re-running only failed jobs
  • Job dependency graph
  • Automatic deletion of expired artifacts
  • Deployment environments API — list of environments with the latest state
  • Runners: management API and job routing by label, the external runner program (gitriver-runner: receiving jobs, running them, live logs, artifacts and cache, cancellation)

Partially Implemented

Capability What’s there What’s missing
Step execution A job’s steps run in a single shell session: cd, variables, and files from one step are visible to the next (except steps that run as a separate process) No isolated step execution and no separate exit codes per step
Step conditions if: on a step: expressions, if: always(), if: failure() always() and failure() take effect only when they make up the whole step condition: inside an expression (failure() && $X) they refer to the outcome of the job’s dependencies, not of the previous steps
Actions (uses:) — see the section Four source forms, using: composite/docker/node, with: → INPUT_* ${{ }} expressions, action outputs, inputs.default, args/entrypoint, runs.pre/post