Skip to main content
Version: v5.7.0

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
  • --local pins the lane, so a runner that happens to carry an OPSCHAIN_SERVER or a stored context can never redirect the job to a server.
  • --approve-all answers every wait: 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 json prints 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 codeMeaningJob result
0the run succeededpass
1the run failed, or the command itself failedfail
2usage error — bad flags, a lane refusal, a malformed k=vfail, fix the job
3the run was cancelled — a wait step with no terminal and no --approve-all, or an interruptfail
4the 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 as OPSCHAIN_MASTER_KEY; it is tried first, before any file or keychain.
  • A key file. Write it to $OPSCHAIN_HOME/secrets/master.key with mode 0600 (looser permissions are refused) and pass --no-keychain so 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 -i inputs.

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 runWhyOn a server instead
change: steps that spawn a child change on another nodethere is no estate to spawn it on; the local lane refuses the step by namethe child change runs against a real node (lesson 2.2, lesson 3.1)
content() and list() over another commit of the repositorythe local lane resolves them against the server's copy of the repositorya 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 packsa server fetches and pins the pack (lesson 2.4)
a run anyone can cite as evidencenothing records who ran it, no audit event is written, no approval was givenraise 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 json is 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.
  • --ref pins a clean commit; opschain runs artifacts --download hands collected artifacts to the CI system; opschain logs --local reads a failed step back.
  • Secrets reach the job through OPSCHAIN_MASTER_KEY or a 0600 key 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).