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 patternstags/tags-ignore— tag glob patternspaths/paths-ignore— the workflow runs only if files matching the pattern changedbranchesandbranches-ignoreare mutually exclusive (cannot use both)- If
pathsis 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:*.somatcheslib.soin the root, but nottarget/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 bothsrcitself and everything under it;**/*.rsmatches bothmain.rsandsrc/main.rs;a/**/bmatches botha/banda/x/y/b;- a pattern without
*or?is an exact path. Character classes ([abc]) are not supported and are compared as literal characters; - in
branchesandtags, 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$VARsubstitution)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
needsmust 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 withoptional: true; failure()is true only if a dependency failed. In the example above,notify-on-faildoesn’t run on branches other thanmain, wheredeployis 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 containerenv— environment variables for the service- If
aliasisn’t specified, the image name before:is used (postgres, redis) - Services run in a Docker network and are reachable by
aliasas 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 codestuck_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): withcancel-in-progress: trueit cancels the running one, without it — queues the new oneinterruptible— cancels by ref (branch/tag), without naming a group explicitly
runs-on — runner label
A job with
runs-onother thandefaultis 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-onto 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_enabledsetting (an installation without Docker), labels aren’t considered at all: the entire queue goes to external runners, includingruns-on: defaultand jobs withoutruns-on. A job without labels may be picked up by a runner with any labels, andruns-on: defaultby a runner that has declared thedefaultlabel.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, orworking-directory: it runs as a separate process, and itscdandexportdon’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,cargofrom it) are available inafter_script— they might not exist on the host at all, or exist at a different version; $CI_WORKSPACE,$CI_PROJECT_DIR, and$CI_OUTPUTpoint inside the container — the same place as during the script;- the job’s services are still up, and
services:aliases still resolve; - the
after_scriptcontainer 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 intoafter_script; - a
uses:step withif: always()runs in the container too — meaning a Docker action needs Docker inside the job’s image, exactly like the same step withoutif: 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 failuremax-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 withruns-on(external runners), as well as to retrying or manually running a single matrix job. A value of0is 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 onesexclude— 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 segmentsexpire-in— retention period (default:30d)- Formats:
1h,7d,30d,1y,never - Artifacts are available to jobs that depend via
needs. A job withoutneedsgets 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 byneeds, 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, butlink/*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 activeCI_JOB_TOKEN),.docker(the container registry account afterdocker login),.ci-output(the$CI_OUTPUTfile), 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 fromgitby.git/info/exclude, sogit add -Ain the job’s script doesn’t add them .gitdoesn’t end up in the archive — neither matched by a glob, nor named by path: the working copy’s.git/configholds the job’s LFS access (lfs.urland anAuthorization: Basicheader with the job’s token), andpaths: ['.']would carry it into the archive as plain text. The rule works the other way too: on restore,.gitentries from the archive are skipped — the working copy’s.gitalways 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
pathsare 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 withnode_modulesis 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: differenttarbuilds 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
pathsthat 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 anddotenvvariables didn’t reach dependent jobs. The$CI_OUTPUTfile 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 lagexpire-inby that long. The server sets the deadline — the same for an archive from the built-in runner and from an external one; ifexpire-inisn’t set or the value isn’t recognized, the30ddefault applies. The archive’s size counts against themax_ci_artifactsquota 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$VARsubstitution)paths— directories to cache. Unlikeartifacts: 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 .gitdoesn’t end up in the cache and isn’t restored from it — the same rule as withartifacts- A name with a backslash (
\) doesn’t end up in the cache — the same rule as withartifacts; 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 writepull— 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:
- A job writes
key=valuepairs to the$CI_OUTPUTfile (one per line) - After the job finishes, its outputs are saved on the server
- 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
_
- 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’sartifacts.reports.dotenvare 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)
- Installation-, group-, repository-, and environment-level variables (“CI/CD variables” screens)
- Global
env:from the workflow file - Job-level
env: - Run input:
INPUT_*fromworkflow_dispatchandCI_MERGE_REQUEST_* - Predefined
CI_*variables - 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), unlikeCI_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: defaultand a job with noruns-onat all. - A runner needs the
defaultlabel. A job withoutruns-onis picked up by any runner, whileruns-on: default— only by a runner that has declared thedefaultlabel. If no runner has that label,runs-on: defaultjobs wait for a runner up toci_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:, andneeds: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, underci_data_path(seecache). The only condition is that the runner isn’t older than the server: a runner that doesn’t know about cache exchange runscache:as a silent no-op, and one that doesn’t know about artifact exchange gets a401in 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
needsnesting - 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
needsis 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) withinputs: string, boolean, choice, number - Dependencies between jobs (
needs), including optional ones (optional) - Evaluating
if:conditions — comparisons, regular expressions, functions, logical operators - Matrix (
strategy: matrix) withinclude/exclude,fail-fast,max-parallel - Concurrency groups (
concurrency) withcancel-in-progress - Interruptible jobs (
interruptible) — automatic cancellation on a new push to the same ref - Running in Docker via
image: - Service containers (
services) withaliasandenv - 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, withmaxandwhenconditions - Allowed failure (
allow-failure) - Manual jobs (run via API and interface)
- Passing data between jobs (
$CI_OUTPUTand$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 |