2.8 Running OpsChain in CI
Included from Basic tier
After this you can: run an action or a workflow from a CI job with nothing but the binary — unattended, exit code as the verdict, the run document and artifacts kept as job outputs — and say which things a CI run cannot do, and what to do on a server instead.
Before you start — tick each one:
- Lesson 2.3 — Waits and gates (
opschain learn 2.3) - Lesson 2.6 — The local development loop (
opschain learn 2.6)
In the GUI: nothing — a CI run is a local run; the browser shows a server.
Everything lesson 2.6 did in your terminal, a CI job can do on a runner
with no OpsChain server anywhere: install the binary, run the repository's
actions and workflows off the checked-out commit, and turn the run into a
job result. The machine contract is small and deliberate — five exit
codes, one JSON document, one artifact verb — and this lesson walks it in
the order a pipeline needs it. The exact contract, with the published
GitHub Action and GitLab CI component that package it, is
opschain docs ci.
1. Install the binary in the job
Every job starts by putting opschain on the PATH. Download the release
archive for the runner's platform and the checksum file next to it, verify
the digest, and unpack — exactly what lesson 0.0 does on a laptop, with the
version pinned so a release cut never changes a job under you:
curl -fsSLO https://github.com/LimePoint/opschain-releases/releases/download/v5.7.0/opschain-v5.7.0-linux-amd64.tar.gz
curl -fsSLO https://github.com/LimePoint/opschain-releases/releases/download/v5.7.0/SHA256SUMS.txt
sha256sum --ignore-missing -c SHA256SUMS.txt
tar -xzf opschain-v5.7.0-linux-amd64.tar.gz
install -m 0755 opschain /usr/local/bin/opschain
opschain version
Releases are invitation-only for now, so the download needs the GitHub
token LimePoint issued your organisation: keep it as a masked CI secret
named OPSCHAIN_RELEASES_TOKEN and fetch the two files through the GitHub
API instead of the plain URL above (the published action and the GitLab
template do this for you when the variable is set):
api=https://api.github.com/repos/LimePoint/opschain-releases
id=$(curl -fsSL -H "Authorization: Bearer $OPSCHAIN_RELEASES_TOKEN" -H "Accept: application/vnd.github+json" \
"$api/releases/tags/v5.7.0" | jq -r '.assets[] | select(.name == "opschain-v5.7.0-linux-amd64.tar.gz") | .id')
curl -fsSL -H "Authorization: Bearer $OPSCHAIN_RELEASES_TOKEN" -H "Accept: application/octet-stream" \
-o opschain-v5.7.0-linux-amd64.tar.gz "$api/releases/assets/$id"
The checksum file is fetched the same way. Without the token the plain download answers 404.
The published GitHub Action and GitLab component do this step for you
from a version input; a hand-written job does it once in a setup step
or bakes the binary into the job image.
2. The repository is already configured
A local run reads .opschain/config.yaml from the repository it runs in
— the project code, the environments, the properties root — and the
action files under .opschain/actions. Those are committed files, so a
checkout is a configured repository: there is no login, no context, no
server address to supply. If the repository has not been scaffolded yet,
opschain init writes the two starting files and never touches an
existing one (opschain docs cli/init); commit what it wrote.
The job's checkout is the working tree the run snapshots, so a CI run is a working-tree run of the commit the job checked out. Section 6 tightens that.
3. Run a change, unattended
The three flags a pipeline always passes:
opschain runs create change deploy --env dev --local --approve-all -o json
--localpins the lane, so a runner that happens to carry anOPSCHAIN_SERVERor a stored context can never redirect the job to a server.--approve-allanswers everywait:step with its defaults. Without it a wait step has no terminal to ask, and the run cancels with exit code 3 rather than hanging the job (lesson 2.3 showed the refusal).-o jsonprints one run document on stdout when the run finishes and nothing else — no live tree, no streamed log lines — so the output is parseable as it stands.
Inputs travel as -i name=value, repeatable, validated against the
action's declarations before anything runs:
opschain runs create change deploy --env dev --local --approve-all -o json \
-i release="$CI_RELEASE" -i dry_run=false
4. Assert the exit code
The process exit code is the run's outcome, and a pipeline reads nothing else to decide pass or fail:
| Exit code | Meaning | Job result |
|---|---|---|
| 0 | the run succeeded | pass |
| 1 | the run failed, or the command itself failed | fail |
| 2 | usage error — bad flags, a lane refusal, a malformed k=v | fail, fix the job |
| 3 | the run was cancelled — a wait step with no terminal and no --approve-all, or an interrupt | fail |
| 4 | the run timed out (--timeout, --step-timeout, or the action's own) | fail |
A shell step that lets the exit code through needs nothing more; a step that captures the document and wants to keep going does it explicitly:
set +e
opschain runs create change deploy --env dev --local --approve-all -o json > run.json
status=$?
set -e
echo "exit code $status"
The document carries the same verdict as three booleans — failed,
cancelled, timed_out — so a reader that only has the JSON can still
tell the four failing outcomes apart.
5. Read the run document
The document is {"run": …, "steps": [...], "failed", "cancelled", "timed_out"}: the run summary (id, kind, action, target, status,
commit, timestamps), then the step tree with every step's path, kind and
status. Pull what the pipeline needs with any JSON tool:
jq -r '.run.id' run.json
jq -r '.run.status' run.json
jq -r '.steps[] | select(.status != "success") | "\(.path): \(.status)"' run.json
01a1b365-bc34-731b-992e-469b83b00afa
success
The id is the handle for everything after the run. The step logs did not
go to stdout (-o json keeps it clean), so read them back from the
job's local history when a step fails:
opschain logs "$(jq -r '.run.id' run.json)" --local
[deploy/build] building release 1.2.0
[deploy/release] released
Both -o json and -q suit a pipeline: -q prints only the run id and
leaves the exit code as the verdict, for a job that keeps no document.
6. Pin the commit with --ref
The checkout is a working tree, and in CI it is usually clean — but a
job step that generated files before the run would snapshot them too.
--ref runs a clean snapshot of a commit instead of the working copy,
and the run document records the exact revision under commit:
opschain runs create change deploy --env dev --local --approve-all -o json --ref "$GIT_COMMIT"
jq -r '.run.commit' run.json
eaa9ecfe14a700eb12dd773fb5e540e1281a90f5
That is the line a release pipeline wants on record: this commit, this
action, this outcome. Without --ref the same field tells you what the
run really proved: a clean checkout records the bare SHA, and a tree the
job had already written into records <sha>+dirty.<digest>. An action
file that is not in the commit is refused by name, the same rehearsal
rule as lesson 2.6.
7. Upload artifacts
A step that declares artifacts: has them collected at completion, failed
and cancelled runs included (lesson 3.6). Pull them out of the run before
the job ends and hand them to the CI system's own artifact store:
opschain runs artifacts "$(jq -r '.run.id' run.json)" --local
NAME FILES SIZE TYPE CREATED BY
integration_report 3 12.4kB application/x-tar 2026-11-07T00:25:51Z local
opschain runs artifacts "$(jq -r '.run.id' run.json)" --local --download integration_report --out ./opschain-artifacts
A collected artifact unpacks under --out with its workspace-relative
paths; a raw artifact lands as one file. On the local lane a workflow run
lists the artifacts of every change it spawned, so one download loop
covers a whole workflow (opschain docs cli/runs-artifacts). The
published action does this when artifacts: true and attaches the
directory with the job.
8. Run a workflow
The same contract runs a repository workflow — the code under
.opschain/workflows, or a file path:
opschain runs create workflow nightly --env dev --local --approve-all -o json
Exit codes, the document and the artifact verb are identical; in the
document a change step's nested_run_id names the change it spawned,
whose logs opschain logs <id> --local reads.
9. Secrets, unattended
Secret inputs and secret-vault:// references resolve through the
job's local secret store, which is encrypted with a master key
(opschain docs cli/secret). On a laptop that key lives in the OS
keychain; a CI runner has no keychain, so give the job the key one of two
ways:
- The environment variable. Store the
AGE-SECRET-KEY-1…identity as a masked CI secret and export it asOPSCHAIN_MASTER_KEY; it is tried first, before any file or keychain. - A key file. Write it to
$OPSCHAIN_HOME/secrets/master.keywith mode0600(looser permissions are refused) and pass--no-keychainso no store command ever reaches for a keychain.
Then seed the store from the pipeline's own secrets on stdin, never on the command line, before the run:
printf '%s\n' "$DB_PASSWORD" | opschain secret set db/password --no-keychain
secret db/password set
The store, the keyset and the local history all live under the opschain
home — set OPSCHAIN_HOME to a directory inside the job workspace so
nothing outlives the job on a shared runner, and so two jobs on one
machine never share a store.
2026-11-11 — a direct
secret-env://reference from the job's environment and a passthrough of named job variables into steps are landing on the local lane in the same release; until then, seed the store as above and pass values as-iinputs.
10. Images: pre-pull in a prior step
A local run never pulls a container image — the lane is offline-first, and
a step whose image: is not present fails by name rather than reaching
for a registry (opschain docs runner-executors). There is no OpsChain
verb for pulling; it is the job's responsibility, in a step before the
run, with the CI system's own cache doing the rest:
docker pull ghcr.io/acme/build-tools:1.4
opschain runs create change deploy --env dev --local --approve-all -o json
The job's environment does not reach steps either: a hostexec step sees
only the standard allowlist (HOME, the temp and locale variables, TZ,
USER and the proxy variables), so a CI variable a step needs travels as
an -i input or a property, not as an exported variable.
11. What a CI run cannot do — and the server lane for each
A CI run is a local run, so lesson 0.2's line applies unchanged. Stated plainly, with where the capability lives:
| Not in a CI run | Why | On a server instead |
|---|---|---|
change: steps that spawn a child change on another node | there is no estate to spawn it on; the local lane refuses the step by name | the child change runs against a real node (lesson 2.2, lesson 3.1) |
content() and list() over another commit of the repository | the local lane resolves them against the server's copy of the repository | a server change resolves them from its git remote (lesson 3.3) |
packs declared with url: or remote: | the local lane mounts the bundled pack and bundle: files, not git-sourced packs | a server fetches and pins the pack (lesson 2.4) |
| a run anyone can cite as evidence | nothing records who ran it, no audit event is written, no approval was given | raise the change on a server from the job — the GitHub integration, or opschain login --server <url> --token "$OPSCHAIN_API_KEY" with an API key minted for the pipeline (lesson 7.3), then runs create change <node> --action <a> --follow with the same exit codes (lesson 3.1) |
The honest split: CI on the local lane proves the commit — the action compiles, the steps pass, the artifacts exist — and a server proves the change. A pipeline that needs both does the first in its test stage and the second in its release stage.
Check yourself
- A wait step is in the action and the job has no
--approve-all: what exit code does the job see, and which flag fixes it? Which single document line tells you which commit the run executed?
What you learned
- A CI job is a local run: install the pinned binary, verify its digest, and the checked-out repository is already configured.
--local --approve-all -o jsonis the unattended contract; the exit code (0/1/2/3/4) is the verdict and the run document carries the id, the commit and every step's status.--refpins a clean commit;opschain runs artifacts --downloadhands collected artifacts to the CI system;opschain logs --localreads a failed step back.- Secrets reach the job through
OPSCHAIN_MASTER_KEYor a0600key file with--no-keychain; images are pulled in a prior step; the job's environment stays out of steps. - A CI run proves the commit, never the change:
change:steps, repository content from other commits, git-sourced packs and evidence all live on a server.
Next: this closes the module Author work — lesson 3.1 runs the same action on a server (opschain learn 3.1), or back to your track (opschain learn).