Skip to main content
Version: v5.7.0

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.

One rule for a shared action's inputs: splice ${{ inputs.release }} only for a plain token like a version. An input a caller may fill with JSON, tags, a script or anything else carrying quotes, $, braces or newlines is read from its environment variable instead — OPSCHAIN_INPUT_<NAME>, the input name upper-cased, holding the raw value — so the shell never re-parses what the caller supplied:

inputs:
parameters: { type: string, required: true }
steps:
- name: apply
run: azu deployment apply --parameters "$OPSCHAIN_INPUT_PARAMETERS"

OPSCHAIN_INPUT_FILE_<NAME> names a file holding the same bytes, for a tool that takes a path (and the only form for a value over 32 KiB). Spliced JSON loses its quotes before the tool sees it — the failure an operator discovers one action at a time.

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 a rev: 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 --local and -i key=value.
  • Splice ${{ inputs.x }} for plain tokens only; a quote-bearing input is read from "$OPSCHAIN_INPUT_X" (or the file $OPSCHAIN_INPUT_FILE_X names), so the shell never re-parses it.
  • Splitting it out means moving the directory into its own repository and mounting it with url:/rev: — then remote: 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).