2.4 Using packs
Included from Basic tier
After this you can: mount a maintained pack, call one of its actions by name, and browse the pack store from the operator's seat.
Before you start — tick each one:
- Lesson 2.2 — Namespaces, composition and inputs (
opschain learn 2.2)
In the GUI: the Packs panel in the Operations area — the installed pack store: signed bundles, versions and pins.
You do not have to write every wrapper yourself. An action pack is a
repository of reusable actions — run a committed script, a terraform
plan, an ansible playbook, a container or machine image build — that your repositories mount by name and
call like any other action. In this lesson you mount the common pack
that ships with the product, call script:execute from it in a local
run, and read what the operator sees in the pack store.
1. The pack model
A repository opts in with .opschain/packs.yaml at its root:
packs:
- name: script # the namespace the pack's actions mount under
path: script # the pack's root inside a monorepo (optional)
# no remote/url: resolves from the opschain-pack-common copy BUNDLED
# with this release — zero setup, works air-gapped, pinned to the
# bundled sha. Declare a remote: or url: to use a newer copy instead:
# remote: opschain-pack-common # a git remote configured on the install
# rev: main # branch, tag or sha — production pins a tag or sha
opschain-pack-common ships with the product. A declaration with no
remote: or url: (or an explicit bundled: true) resolves from the
snapshot vendored into this release — the version tested with your binary,
usable with zero configuration, offline, and in --local runs. The pin
records bundled@<sha> and the step preamble says so. A bundled entry may
not carry rev: (the bundle has exactly one sha — the error names it);
declaring an explicit remote:/url: always overrides the bundle when you
want newer pack content between releases. The common pack is granted on
every tier — packs:common is always granted by licence resolution, so
applying an enterprise licence can never revoke it — and packs you write
yourself are never licence-gated; only a commercially licensed LimePoint
pack refuses without its packs:<name> entitlement
(opschain docs entitlements).
Four ideas carry the whole model:
-
name:is the mount point. The pack's actions appear in your catalog under it:script:execute. Pick a name that does not collide with one of your own action namespaces — a collision is a named error. -
remote:is an indirection, not a URL. It names a git remote configured on the install, so the same repository works unchanged on a connected site (remote → github.com) and an airgapped one (remote → the internal mirror). For quick development there is also an inlineurl:form — declare exactly one of the two. -
rev:pins. When the server resolves your packs (at template version resolution, or when a change is created from a git remote), every pack is frozen to an exact commit sha and recorded — a pinned version reproduces forever, and bumping a pack is a deliberate act. -
Locally, git-sourced packs come from the pack cache. A local run never contacts a remote: run
opschain packs fetchonce in the repository and eachremote:/url:declaration is resolved to an exact commit under<opschain home>/packs/<name>/<sha>(locally,remote:names one of the repository's own git remotes); every later local run mounts it offline, and fetching again is how a branchrev:moves. A pack manifest the tree carries is verified like a signed bundle, and one that does not verify is never cached (opschain docs cli/packs-fetch). -
installed:mounts an uploaded bundle. A signed pack bundle installed on the platform (opschain admin packs install, or the Packs panel — the air-gapped distribution lane) mounts withinstalled: <pack name>instead of a git source:packs:- name: awsinstalled: opschain-pack-aws# version: 1.2.3 # optional: pin an exact installed versionAdd
version: 1.2.3to pin an exact installed version; omit it and resolution seals the pack's pinned version (if an administrator set one) or the highest installed version at template-version resolution time (opschain docs cli/admin-packs). To roll a newly installed version out across the estate,opschain templates re-resolve --pack <name>cuts one new template version per template that resolves the pack through an unversioned declaration, moving only the pack pin — explicitversion:pins and already-sealed versions never move (opschain docs cli/templates-re-resolve). An unversioned declaration seals the estate default — the admin pin, else the highest installed version — unless you choose a version for one template version yourself:opschain templates version upsert <project>/<code> --version <v> --pack <name>=<version>(or "Select version" on the version page's Sealed packs panel) sets an explicit per-version pin that a later Fetch revision keeps, while every other pin re-resolves as usual;--pack <name>=clears it back to the default. A template version pins its Git SHA, and its pack versions the same way. Re-resolve's--versionwrites the same explicit pin on every version it cuts. A locked version takes no pin change — unlock it first. -
bundle:mounts a signed bundle file in--localruns. The same signed.bundleartefact the installed store accepts can be mounted straight from disk for a local run —bundle: dist/mypack_v1.2.3.bundle(relative to the repository root, or absolute). The file is verified exactly as a server upload is — manifest signature, every per-file digest, and any entitlement its signed manifest requires — before anything runs, and${{ pack.dir }}resolves into the verified content. There is no store and no pin: the file on disk is the version. A server never reads local disk, so on a server the same entry is a named refusal pointing atinstalled:. -
References are always fully qualified. You call
uses: script:execute; your own bareuses:references never accidentally resolve into a pack, so adding a pack can never change what existing actions do.
2. Call a pack action: script:execute
The common pack's script namespace runs a script committed in your
repository — no wrapper authoring at all. With the pack declared as
above, add scripts/hello.sh and an action that calls it:
#!/bin/sh
# scripts/hello.sh
echo "hello from the script, $1"
# .opschain/actions/run_script.yaml
action: run_script
steps:
- name: say_hello
uses: script:execute
with: { path: scripts/hello.sh, args: "acme" }
- name: report
run: echo "script said ${{ steps.say_hello.outputs.stdout }}"
Plan it: the pack action's own step appears under yours, exactly like a composed action from lesson 2.2:
opschain runs create change run_script --env dev --plan --local
· run_script
├─ · say_hello
│ └─ · execute
└─ · report
Run it. The bundled pack needs no server and no network:
opschain runs create change run_script --env dev --local
2026-11-07T00:26:25Z run_script/say_hello/execute queued -> running
[run_script/say_hello/execute] ... script:execute running [ scripts/hello.sh ] via [ /bin/sh ] with 1 argument(s)
[run_script/say_hello/execute] hello from the script, acme
2026-11-07T00:26:26Z run_script/say_hello/execute running -> success
2026-11-07T00:26:26Z run_script/report queued -> running
[run_script/report] script said ... hello from the script, acme
2026-11-07T00:26:26Z run_script/report running -> success
2026-11-07T00:26:26Z run_script running -> success
run 01a1b366-4281-7358-960c-0f1dba0b7717 success (1.1s)
Pack actions declare typed inputs the same way lesson 2.2's composed
actions do — path: here is required, so forgetting it fails at plan
time with the missing input named, before anything runs. They publish
outputs too: script:execute captures the script's stdout and
exit_status for later steps to consume — no log-scraping.
On a server the same action runs as a change on an asset (lesson 3.1),
and the step's log carries the pack provenance — which pack, from which
remote, at which pinned sha — in the [system] preamble, with the
script's own output after it.
Inside a pack's actions, ${{ pack.dir }} resolves to the pack's own
checkout in the step workspace — that is how a pack ships helper scripts
without your repository carrying them. Reference every pack-shipped file
through it (run: '"${{ pack.dir }}/scripts/backup.sh"'): a bare relative
path resolves against the estate workspace, not the pack. During the
in-repo development loop (lesson 2.5) the same reference resolves to
the repository checkout — the repo is the pack root there — so the one
spelling works before and after the split-out.
3. The pack store, from the operator's seat
installed: entries resolve against the platform's pack store —
signed bundles uploaded as whole versioned units, the distribution lane for
commercially licensed packs and for air-gapped estates. The verbs are
administrative (opschain docs cli/admin-packs covers standing and flags);
here is the loop end to end.
See what is installed:
opschain admin packs list
NAME VERSION REQUIRES MIN_PLATFORM PINNED REFERENCED BY FILES KEY
opschain-pack-infrastructure 1.2.3 packs:infrastructure 5.4.0 yes 0 tv / 0 runs 24 2026-a
opschain-pack-infrastructure 1.2.2 packs:infrastructure - no 2 tv / 3 runs 23 2026-a
Versions co-exist deliberately — different template versions can pin
different pack versions and run side by side, and keeping prior versions is
what makes rollback a re-pin. REFERENCED BY tells you, before you act,
what holds each version: the template versions that sealed it (a
re-resolve moves them onto another version) and the runs that executed
from it (retention releases those) — a delete refuses while either count
is non-zero. -o json lists the sealing template versions by project,
template and version so you can find them; the template version's own
page shows the same relation from its side (sealed_packs).
Install a signed bundle:
opschain admin packs install -f opschain-pack-infrastructure_v1.2.3.bundle
Installed opschain-pack-infrastructure version 1.2.3 (24 files, signed by 2026-test)
The server verifies the bundle offline against its embedded keyring before a byte is stored — manifest signature, every per-file digest — and each failure refuses by name (bad signature, digest mismatch, unknown signing key). Versions are immutable: re-uploading the same version with identical content is idempotent, with different content it refuses — a fix is always a new version.
Pin the version unversioned declarations get:
opschain admin packs pin opschain-pack-infrastructure 1.2.2
Pinned opschain-pack-infrastructure to version 1.2.2
Pinning steers future resolutions only — template versions that already
resolved keep the exact version they sealed — which is exactly what makes it
the rollback lever: pin the prior version, then re-resolve
(opschain templates re-resolve --pack <name>). --clear restores the
default, the highest installed version. To move just one template
version, skip the estate pin and set its own: opschain templates version upsert demo/web_stack --version 1.1.0 --pack opschain-pack-infrastructure=1.2.2
— the version's sealed_packs then shows the pack as decided_by: explicit, and fetching its revision keeps the choice.
When the licence lacks the pack's entitlement. The REQUIRES column is
the pack's own declaration: a commercially licensed pack's signed manifest
names the packs:<name> entitlement it needs. Installing such a bundle on a
licence without the entitlement is refused naming it:
this surface requires the "packs:infrastructure" entitlement - the basic licence does not include it; apply a licence that grants it (opschain admin licence apply)
And if the entitlement lapses after install, the stored pack stays put but creating a change that uses it refuses with the named error:
action pack "<pack>" (mounted as "<mount>") requires the "<feature>" entitlement — the <tier> licence does not include it; apply the licence pack that grants it (opschain admin licence apply / PUT /api/licence)
Applying the licence pack lights everything back up — nothing is reinstalled. Packs you write yourself carry no signed manifest and are never gated.
Read more: opschain docs pack-store (the store's lifecycle in full),
opschain docs entitlements (tiers, grants and every named refusal),
opschain docs packs-common (the bundled pack's action catalog).
Check yourself
- What does
.opschain/packs.yamlpin, and what in a step's output tells you which pack action ran and how?
What you learned
.opschain/packs.yamlmounts a pack under aname:, and the common pack resolves from the copy bundled with your binary when you name no source.- You call a pack action fully qualified (
uses: script:execute), bind its typed inputs withwith:, and read its outputs like any composed action. - The bundled pack runs in
--localwith no server and no network;rev:,installed:andbundle:are the other three sources — and a git-sourced pack runs locally too, from the pack cacheopschain packs fetchfills. - The pack store holds signed, immutable, co-existing versions; a pin steers future resolutions and is the rollback lever.
Next: lesson 2.5 — Building a pack (opschain learn 2.5), or back to your track (opschain learn).