2.5 Building a pack
Included from Basic tier
After this you can: build a pack of your own in-repo, iterate on it locally, and recognise every failure mode a pack can report.
Before you start — tick each one:
- Lesson 2.4 — Using packs (
opschain learn 2.4)
In the GUI: none — pack development is a working-tree activity.
A pack is just a repository whose actions live under .opschain/actions.
In this lesson you grow one the comfortable way: as a namespace inside
the repository you already have, iterated on the local lane, then split
out and hardened when it is ready to share.
1. Start in-repo, as a namespace
Author the future pack's actions as a namespace directory in the
repository you are working in — .opschain/actions/mypack/banner.yaml
is mypack:banner — and give it a summary: and an input, the way a
shared action should:
# .opschain/actions/mypack/banner.yaml
action: banner
summary: Print a banner naming the release
inputs:
release: { required: true }
steps:
- name: print
run: echo "=== release ${{ inputs.release }} ==="
Iterate with the local lane from lesson 2.1, supplying the top-level
input with -i:
opschain runs create change mypack:banner --env dev --local -i release=1.2.0
2026-11-07T00:25:50Z mypack:banner/print queued -> running
[mypack:banner/print] === release 1.2.0 ===
2026-11-07T00:25:50Z mypack:banner/print running -> success
2026-11-07T00:25:50Z mypack:banner running -> success
run 01a1b365-b942-7bb1-8a7c-6761728c5d89 success (0.1s)
Fast feedback, no server round-trip, and the identities are already their final shape. The catalog shows it beside your own actions, with the summary you gave it:
opschain actions list --local
ACTION SOURCE INPUTS APPROVALS STATUS SUMMARY
hello repo no no Example action scaffolded by opschain init
mypack:banner repo no no Print a banner naming the release
2. Split it out when it is ready to share
Move the directory's contents into their own repository — the files land
under that repo's .opschain/actions/, either directly
(actions/banner.yaml) or still inside the namespace directory
(actions/mypack/banner.yaml, the layout the maintained cloud packs
ship); both answer mypack:banner once the pack is mounted under that
name — and declare it where you want to use it:
packs:
- name: mypack
url: https://git.example.com/mypack.git # the inline dev form
rev: main
Every call site keeps working — uses: mypack:banner resolves to the
mounted pack instead of the local directory (remove the local directory;
keeping both is the named collision error from lesson 2.4). While rev:
names a branch, each new template version or git-remote change re-pins
at that branch's current commit — your pack development loop — and a
release is just repointing rev: at a tag.
Harden it for production. Swap url: for a configured remote: name
and a tagged rev:. Configuring git remotes is an administration task
covered in lesson 3.3.
Rehearse the published artefact. If you distribute your pack as a
signed bundle, mount the built .bundle file itself in a --local run
with bundle: <path> before publishing — the exact artefact your users
will install, verified and resolved exactly as the platform will verify
and resolve it, ${{ pack.dir }} included.
A pack may declare its own packs.yaml to build on other packs (the
dependency graph must be acyclic, and every declaration of a pack name
must pin the same commit — disagreements are named errors).
3. When things go wrong (loudly, by design)
A missing required input is refused before anything runs, with the action and the input named:
opschain runs create change mypack:banner --env dev --local
Error: action mypack:banner input release is required and was not supplied
An action that does not exist is refused the same way:
Error: no action file for action "nothere" (tried .opschain/actions/nothere.yaml) and no actions.rb in the repository
The other failure modes report just as plainly:
- A
with:key the action never declared: refused at plan time with the step and the action named (lesson 2.2). - A
remote:that is not configured, or arev:that does not exist: pack resolution fails naming the pack, the remote and the rev — nothing half-runs. - A toolchain the runner lacks (say the script's interpreter): the step fails with the missing tool named. What a runner host carries is lesson 6.2's territory.
Check yourself
- What makes a directory a pack namespace, and what does a run print when a required input is missing?
What you learned
- A pack starts as a namespace directory in your own repository, iterated on with
--localand-i key=value. - Splitting it out means moving the directory into its own repository and mounting it with
url:/rev:— thenremote:and a tag for production. bundle:rehearses the signed artefact locally before you publish it.- Every pack failure is named before anything half-runs: the missing input, the unknown action, the unresolvable remote or rev, the absent tool.
Next: lesson 2.6 — The local development loop (opschain learn 2.6), or back to your track (opschain learn).