Skip to main content
Version: v5.7.0

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:deploy missing 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 failed with an owner-resolution error — re-read lesson 5.2 (flat YAML keys, everyday operator, not the bootstrap admin).
  • Section 4: no stored artifact line or no api:artifacts:create row — re-read lesson 3.6 (put needs a server target, --node, --code and -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_release failed 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_release while the first deploy is still running: which artifact does the first deploy install, how do you prove it from opschain logs, and which id in the trigger's resolved action_params made that true?

What you learned​

  • The hand-off pattern: publish → api:artifacts:create (carrying artifact_id, code, digest) → an artifact-subject trigger → ${{ event.data.artifact_id }} spliced into the consumer's property_overrides → artifacts get --id in 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 test against a real api:artifacts:create event proves the splice before you let it run the estate.

Next: this closes the module Events and automation — back to your track (opschain learn).