5.7 Lab: the hand-off pattern
Included from Basic tier
After this you can: apply artifacts, triggers and chains without new verbs: a producer publishes, a trigger splices the exact id, a consumer fetches digest-verified — on one chain.
Before you start — tick each one:
- Lesson 3.6 — Artifacts (
opschain learn 3.6) - Lesson 5.2 — Triggers (
opschain learn 5.2)
In the GUI: the chains screen shows producer, trigger firing and consumer under one correlation id.
This lab introduces no new verb. It takes artifacts put and
artifacts get --id (lesson 3.6), triggers create, test and
firings (lesson 5.2) and chains show (lesson 5.1) and wires them into
the backbone of a self-running delivery pipeline: a producing run
publishes an artifact → the platform announces it → a trigger splices
the artifact's exact id into a consuming run → the consumer fetches
precisely those bytes. No shared directories, no "latest" races, and
the whole flow lands on one auditable chain. Every step says what you
should see; if a step did not show that, the closing section names the
lesson to re-read.
1. The pattern at a glance
producing run the platform consuming run
───────────── ──────────── ─────────────
builds web-release.tar.gz
stores it as an artifact → api:artifacts:create event
(artifact_id, code, digest…)
│
trigger: subject artifact,
condition on event.data.code,
splices ${{ event.data.artifact_id }}
into the new change's properties
│
→ deploy change created,
fetches by exact --id,
digest-verified
2. The consumer's action, written first
The consuming action reads a spliced property and fetches with --id —
the digest-verified, deterministic read from lesson 3.6. Put it in the
web template's repository (lesson 3.4) so web:deploy is in the test
asset's catalog before anything fires:
# .opschain/templates/web/actions/deploy.yaml — the web template's web:deploy action
action: deploy
steps:
- name: fetch_release
run: opschain artifacts get --id "${{ properties.release_artifact_id }}" --out release.tar.gz
- name: install
run: ./install.sh release.tar.gz
You should see: opschain actions list --project acme --env test --asset web lists web:deploy once the template version carrying it is
assigned.
3. The trigger splices the id
An artifact-subject trigger watches for the announcement and creates the
consuming change, carrying the artifact's id across in a property
override. The ${{ event.data.… }} templates in action_params resolve
per firing, against the matched event:
# deploy_on_artifact.yaml
code: deploy_on_artifact
description: deploy to test whenever a web_release artifact is published
subject: artifact
events: [created]
scope_path: /projects/acme
conditions:
- event.data.code eq web_release
action_type: change
action_params:
parent_path: /projects/acme/environments/test/assets/web
action: web:deploy
property_overrides:
release_artifact_id: "${{ event.data.artifact_id }}"
opschain triggers create acme -f deploy_on_artifact.yaml
You should see:
created trigger acme/deploy_on_artifact (01a14a01-…-example)
The scope is the project, the condition narrows to the one artifact code
— publishing anything else under acme never fires it. (Subject
artefact is accepted as a spelling alias and stored as artifact.)
Create it as the everyday operator, not the bootstrap admin, or every
firing will fail by name (lesson 5.2).
4. The producer publishes
Every artifact store emits api:artifacts:create, carrying the identity
a consumer needs: artifact_id, code, labels, node_path, digest,
size. It does not matter how the store happens — a run's own build
step, a declared artifacts: collection on the step (lesson 3.6), or
any process with a token running:
opschain artifacts put --node acme/environments/dev --code web_release \
-f dist/web-release.tar.gz
You should see:
stored artifact web_release (01a14a10-…-example, 4302918 bytes) against /projects/acme/environments/dev
and, in the feed, the announcement with that id in its payload:
opschain events --filter type=api:artifacts:create --tail 1
WHEN ID TYPE NODE USER CORRELATION DETAIL
2026-10-12T10:14:03.902114Z 01a14a11-…-example api:artifacts:create /projects/acme/environments/dev alice 01a14a11-…-example -
One detail worth knowing for the audit trail: an artifact stored by a run's step inherits the producing run's correlation chain, so the whole hand-off reads as one cascade; an artifact uploaded from outside any run, as here, starts a chain of its own (ID equals CORRELATION on that row), and the hand-off reads from there.
5. Prove the splice before trusting it
Dry-run the trigger against that event's ID:
opschain triggers test acme/deploy_on_artifact --event 01a14a11-…-example
You should see:
trigger acme/deploy_on_artifact against event 01a14a11-…-example:
enabled: yes
subject/event match: yes
scope match: yes
condition event.data.code eq web_release ✓ actual: web_release
action params: {
"action": "web:deploy",
"parent_path": "/projects/acme/environments/test/assets/web",
"property_overrides": {
"release_artifact_id": "01a14a10-…-example"
}
}
verdict: would fire
The resolved action_params carry the real artifact id spliced in,
without creating anything. (The trigger already fired for real when the
event landed — section 6 reads that firing; the dry run is how you check
a trigger's logic any time, against any stored event.)
6. The consumer fetches by exact id
Read the firing and the change it made:
opschain triggers firings acme/deploy_on_artifact --since 1h
You should see:
WHEN STATUS CREATED ERROR
2026-10-12T10:14:04.310772Z created Change/01a14a12-3b0f -
opschain runs show 01a14a12-…-example
opschain logs 01a14a12-…-example --step deploy/fetch_release
Run: 01a14a12-…-example
Kind: change
Action: web:deploy
Target: acme/environments/test/assets/web
Status: success
…
✔ web:deploy — success (12.4s)
├─ ✔ fetch_release — success (2.1s)
└─ ✔ install — success (10.3s)
wrote artifact 01a14a10-…-example (4302918 bytes) to release.tar.gz
This is the point of the pattern: by the time the consumer runs, a newer
web_release may already exist. A latest-match get would deploy that
one; --id deploys the artifact whose creation caused this run,
bytes verified against the recorded digest. Hand-off by exact identity
makes producer and consumer provably agree — and
opschain runs converged 01a14a12-…-example shows
release_artifact_id attributed to the change layer (lesson 1.3).
7. Watch it on the chain
opschain chains show 01a14a11-…-example
You should see:
initiated by alice (user) · started 2026-10-12 10:14:03Z · 0 runs · 1 changes · 9 events · 13s
+0s api:artifacts:create
+0s api:triggers:fired
+1s └─ web:deploy @ /projects/acme/environments/test/assets/web [template Web 1.8.0] success · 12s 01a14a12-3b0f
The chain reads: the announcement → the api:triggers:fired hop → the
deploy change one depth level down, marked initiated by trigger. If
the deploy fails, chains triage 01a14a11-…-example tails its logs —
the whole hand-off is one story, not four disconnected objects.
If a step did not show that
- Section 2:
web:deploymissing from the catalog — re-read lesson 3.4 (assign the version; the catalog refreshes in the background). - Section 3: the create refused, or every firing
failedwith an owner-resolution error — re-read lesson 5.2 (flat YAML keys, everyday operator, not the bootstrap admin). - Section 4: no
stored artifactline or noapi:artifacts:createrow — re-read lesson 3.6 (putneeds a server target,--node,--codeand-f). - Section 5:
would not fire— the report names the gate; scope and condition are lesson 5.2, the ID-versus-CORRELATION mix-up is lesson 5.1. - Section 6: the firing is
deferred— a maintenance window covers the target, lesson 5.6;fetch_releasefailed with not-found — the override did not reach the change, re-read lesson 3.6's exact hand-off. - Section 7: the chain shows only the announcement — re-read lesson 5.1 (a change you create directly is its own root; the trigger's change hangs off the event's chain, not off yours).
Check yourself
- Publish a second
web_releasewhile the first deploy is still running: which artifact does the first deploy install, how do you prove it fromopschain logs, and which id in the trigger's resolvedaction_paramsmade that true?
What you learned
- The hand-off pattern: publish →
api:artifacts:create(carryingartifact_id,code,digest) → an artifact-subject trigger →${{ event.data.artifact_id }}spliced into the consumer'sproperty_overrides→artifacts get --idin the consuming action. - Exact-id fetch is what makes the pattern safe: the consumer processes the bytes that caused it to run, never whatever is latest by then.
- A step-stored artifact keeps the producing run's chain, so the entire
flow — build, announcement, trigger decision, deploy — reads as one
cascade in
chains show. triggers testagainst a realapi:artifacts:createevent proves the splice before you let it run the estate.
Next: this closes the module Events and automation — back to your track (opschain learn).