chant release
Synopsis
Section titled “Synopsis”chant release cut [<label>] [--yes] [--build <command>] [--param <name=value>]... [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>] [--actor <name>] [--sign [<key file>]] [--json]chant release publish <label|digest> [--yes] [--allow-unsigned] [--require-provenance] [--json]chant release verify <label|digest> [--json]chant release list [--json]chant release export <label|digest> --env <env> [-o <dir>] [--json]A release is the compiled output of one green commit of one member (#4026, ws-104). The commit is the source, and the release is what moves through the environments. A release holds the commit, the artifacts built once from it, and one target per environment. A target lists the templates compiled for that environment and the build parameters they were compiled with.
This is a separate object from a deploy record. A deploy record is a line of <env>/releases.jsonl that chant run --components and chant components promote append when they deploy a component. chant components promote --release deploys one target and names its digest on the deploy record it writes.
release cut
Section titled “release cut”Cuts a release of the member in the current directory from the checkout’s HEAD. A release is only ever cut by this command; chant ci tick never cuts one (ws-105, proposed). See Cutting on green for a pipeline that cuts every green commit, and Release from your workstation for a cut made on a laptop and published from CI.
git fetch origin --tagsgit checkout "$(chant ci last-green)"chant release cut v1.4.0 --build "npm run build" # a dry run: prints the planchant release cut v1.4.0 --build "npm run build" --yes # cuts, records and tagsWithout --yes the cut is a dry run (#4027). It makes every check below and compiles every environment, then prints what the cut would record and tag, and what will be published where. Nothing is built or signed, and nothing is written to chant/lifecycle or tagged. Run the same command again with --yes to cut. The same command runs unchanged in CI, where it needs --yes as well.
The cut refuses before it writes anything when:
| Code | When |
|---|---|
release-worktree-dirty | A tracked file has uncommitted changes, or a file git does not ignore is untracked, so the compile would not be the commit’s. A new *.component.ts left untracked would otherwise be compiled into the release. |
release-commit-not-on-trunk | HEAD is not reachable from origin/<trunk>. Trunk is the branch ci.green.branch names in the declaration at base, else the branch origin/HEAD names, else main. |
release-commit-not-pushed | HEAD is on the local trunk branch but not on origin/<trunk>, or this clone has no origin/<trunk>. The check reads the remote-tracking branch as this clone last fetched it and fetches nothing, so a commit pushed from elsewhere needs a git fetch origin <trunk> first. |
release-commit-not-green | HEAD has no ci/green/<sha> tag. chant ci makes the tags; only local tags are read, so fetch them first. With signed tags, a tag without a valid statement doesn’t count, and the message names it. |
release-commit-revoked | HEAD has a ci/revoked/<sha> tag. |
release-tag-taken | The tag the release would get, release/<label>, already names another commit. |
release-tag-invalid | The label makes a tag name git refuses. |
release-no-environments | The member’s chant.config.ts declares no environments. |
release-no-components | The member declares components, none is enabled in any environment, and nothing was compiled. A project that declares no component is released from its build output instead, see a project with no components. |
release-unsigned | The declaration at base names a release gate in identity.gates, and the cut is not signed. See Who may cut. |
release-signer-refused | The cut’s signature does not verify for the actor against the signers file at base, or the actor is not in the release gate’s class. Nothing is written. |
release-build-inputs-differ | A component’s build steps differ between environments, for example a docker-build whose buildArgs read a parameter that is set per environment. Its artifact can’t be built once for every environment. |
The code is printed in square brackets at the end of the message. The dry run refuses with the same codes, and also with release-label-taken when the label already names a release of another commit.
What the dry run prints
Section titled “What the dry run prints”Dry run: cut v1.4.0 from commit 3f2a9c1d0b7e (on origin/main, green) actor alice@example.com (from git user.email) run minted on this machine at the cut (local) not signed tag release/v1.4.0, made here and not pushed dev: 1 template(s) prod: 1 template(s) api: artifacts built in dev digest decided when the artifacts are builtWhat will be published where: api: publish-image to $env.registry in dev, prod, by that environment's deploy (chant components promote --release v1.4.0) chant release publish v1.4.0: npm (2 packages) to npm the registry npm's config names a publish from here would carry no OIDC provenance: this run is not in CI, and a workstation has no OIDC tokenThe digest is printed when no artifact needs building, since it is then known without a build. --json prints the same as a document with dryRun: true: the commit, label, releaseDigest (or null), alreadyCut, targets, build, reused, publishSteps, signatureRequired, actor and actorSource, run, trunk, sign, tag, publish and next.
What —yes records
Section titled “What —yes records”With --yes the cut builds the artifacts and stores the release. It then appends the cut line, signed when --sign is given. The line names who cut and the run it ran in:
| Field | On a workstation | In CI |
|---|---|---|
actor | --actor, else the principal the signers file at base lists for the --sign key, else git’s user.email, else USER | --actor, else the signer, else GITHUB_ACTOR or GITLAB_USER_LOGIN |
runId | local-<epoch ms>, minted from the cut’s time | GITHUB_RUN_ID or CI_PIPELINE_ID |
runOrigin | { "forge": "local" } | github or gitlab, with the repository and a link to the run |
Everything else in the line, and the whole release, is the same wherever the cut runs. A cut of one commit on a workstation and in CI stores byte-identical release files, and cut lines that differ only in runId, runOrigin, actor and timestamp, and in signature when two keys signed.
The cut then tags the commit release/<label>, or release/<member>/<label> in a workspace member, as an annotated tag whose message names the release digest. The tag is made in this clone only. chant pushes chant/lifecycle when there is a remote and never pushes the tag, so pushing it is a step you take, and a CI workflow can publish on it. A release cut without a label gets no tag. Cutting a release that is already cut is a no-op, and makes the tag if it is missing.
The last lines name the next command. In CI that is chant release publish <label>. On a workstation it is git push origin release/<label>, so that CI publishes. After that it names the publish command and the deploy of the first environment as other choices.
What the cut compiles
Section titled “What the cut compiles”The environments are the entries of the member’s environments array, in order. A pattern entry such as pr-* is not compiled. Each environment is compiled in a child process with CHANT_ENV set to it, since a template is synthesized for one environment per process. In each one the cut:
- runs
--build <command>in the member’s directory, when given, withCHANT_ENVset; - resolves the build parameters the way
chant build --env <env>does, with{env}in a--params-filepath replaced by the environment’s name; - discovers the components enabled under those parameters;
- reads each template their deploys name.
A template is a file named by a literal path in a deploy step’s template, manifest or values field, such as cfn-deploy’s template: "dist/api.template.json". A path with the archive: prefix names the file after the prefix. Build and publish steps are not read, and a path that names a directory, such as a helm chart, is not a template. A template that does not exist after the build stops the cut.
The artifacts are built once. Each component’s docker-build, zip-package and jvm-build steps run, and nothing else, in the first environment the component is enabled in. docker-build gives an image entry and the other two an asset entry, each with the archive path and the digest. When a component’s source did not change since the release cut from the nearest earlier commit, its entries are carried from that release and nothing is built. Unchanged means git sees no change between the two commits in the component’s file, the member’s chant.config.ts, and the paths the build steps read (context and the Dockerfile, source, path). A commit that only changes a flag or a parameter then gets a release of its own with the same artifact digests.
A project with no components
Section titled “A project with no components”A project with no *.component.ts file is cut from what chant build writes (#4046). For each environment the child process builds the project as chant build <src> --env <env> -o <output> would, with the environment’s build parameters, and every file that build writes becomes a template with component: null. The target’s params are the build’s resolved parameters, and artifactDigests is empty.
| Flag | Meaning |
|---|---|
--src <dir> | The build root, relative to the member’s directory. Defaults to chant.config.ts sourceDir, then the member’s directory, as chant lifecycle resolves it. A project that declares stacks must name one. |
-o <file> | The build’s output file, relative to the member’s directory. Defaults to dist/build.yaml. As with chant build -o, a .yaml or .yml name writes YAML and any other name JSON, a lexicon whose output is YAML (k8s, helm) writes YAML whatever the name, and the extra files a lexicon emits are written beside it. |
--lexicon <name> | Build only this lexicon. |
An Op’s IR is written under the build root, so a project with sourceDir: "src" and an Op records src/dist/ops/<op>/op.json as well. The files are kept in memory and nothing is written to the checkout, so a file an earlier build left there is never recorded.
A build is deterministic. Two cuts of one commit record the same bytes for each environment and get one digest, whichever clone or directory they run in.
The release digest and the label
Section titled “The release digest and the label”The release is stored on chant/lifecycle under its digest, and a cut is a line appended to the cut log:
| Path | Holds |
|---|---|
_releases/<releaseDigest>.json | The release, written once and never rewritten. : is written as _. |
_releases/cuts.jsonl | One line per cut: { version, releaseDigest, label, actor, timestamp, runId, runOrigin, signature }. |
_releases/templates/<digest>.txt | Each template’s bytes, by content digest, for a promote to deploy. |
A workspace member’s copies sit under _members/<member>/. The cut writes the local branch and pushes it when there is a remote.
releaseDigest is computePlanDigest("release", subject). The subject is what deploys:
| In the digest | Outside it |
|---|---|
member, commit and artifactDigests | each parameter’s source |
each target’s env and templates | the cut line’s label, actor, timestamp, runId, runOrigin and signature |
each parameter’s name and value | the chant version |
So two cuts of one commit give the same digest.
The label is a human name such as v1.4.0. It starts with a letter or digit, and after that it may also hold ., _, + and -. Within a member, a label names one digest and a digest takes at most one label. Cutting the same digest again is a no-op that prints its label. Cutting it under another label is refused with release-already-labelled, and a label that already names another digest is refused with release-label-taken. A digest cut without a label can be given one by a later cut. signature holds the cut’s signature, or null for an unsigned cut. runId and runOrigin name the run, as What —yes records lists. A cut line written before 0.124.29 has no runId, which reads as null, and its runOrigin is null outside CI.
Who may cut
Section titled “Who may cut”Who may cut a release is an identity.gates entry for a gate named release (#4031), read from the declaration at base like every identity gate:
"identity": { "gates": { "release": { "class": "releaser" } }}With the entry, a cut must be signed with --sign, by a key .chant/allowed_signers at base lists for the actor. With class, the role grants in .chant/trust.json at base must also put the actor in that class. A class no pinned package supplies lets nothing through. Without the entry anyone may cut, and a signature is optional.
--sign <key file> signs with that ssh key, and --sign alone uses git’s user.signingkey when gpg.format is ssh, as chant approve --sign does for a signed gate. When --actor is not given, the actor is the principal the signers file at base lists for that key, if it lists exactly one. --actor names the signer otherwise, as in the cut below.
chant release cut v1.4.0 --actor github:alice --sign ~/.ssh/id_ed25519 --yesUnder the gate the dry run reads the key’s public half and refuses with release-signer-refused when the signers file at base lists it for someone other than the actor. A key whose public half can’t be read without its passphrase is checked when it signs.
The signature is ssh-keygen -Y sign in the chant-release namespace over four lines. They hold the release digest and the label (empty for none), then the actor and the cut’s time. Changing any of them in the cut line stops it verifying. The cut checks the signature and the class before it stores anything.
release list and workspace status check each cut’s signature again on every read, against the signers file and the role grants at base. A key removed from the signers file makes the cuts it signed read as unverified from then on.
| Flag | Meaning |
|---|---|
<label> or --label <label> | The release’s label, such as a version. Optional; a release with no label gets no tag. |
--yes | Cut. Without it the command is a dry run. |
--build <command> | A shell command run in the member’s directory before each environment’s templates are read, with CHANT_ENV set to the environment. |
--param <name=value> | A build parameter for every environment. Repeatable. |
--params-file <file> | A JSON file of build parameters. {env} in the path is replaced by each environment’s name, as in params.{env}.json. |
--actor <name> | Who made the cut. Defaults to the signer of --sign’s key, then GITHUB_ACTOR or GITLAB_USER_LOGIN, then git’s user.email, then USER. |
--sign [<key file>] | Sign the cut with an ssh key, or with git’s user.signingkey when no file is given. See Who may cut. |
--json | With --yes, print the release, its label, the cut line written (null when the release was already cut), whether the release file was written now, and the components whose artifacts were carried from an earlier release, with dryRun: false, actor, actorSource, tag ({ name, created }, or null) and next. Without --yes, print the dry run’s document. |
Cutting on green
Section titled “Cutting on green”A project that wants a release for every green commit runs the cut in its own pipeline, after the tick. Cutting a digest that is already cut does nothing, so the step can run on every tick:
git fetch origin --tagsgit checkout "$(chant ci last-green)"chant release cut --build "npm run build" --yesThe checkout must have origin/<trunk> for the trunk check, so fetch the branch with the tags (fetch-depth: 0 with actions/checkout). The step needs only git, the tags and the member’s build, so it runs on any runner, including one whose tick reads chant runs. Under a release gate in identity.gates the cut must be signed by a signer in the gate’s class. The job then needs that signer’s key, so the declaration decides whether CI may cut.
Revoked releases
Section titled “Revoked releases”A release is revoked when the commit it was cut from gets a ci/revoked/<sha> tag that counts, after the cut (#4033). Nothing is written when that happens: every reader joins the release’s commit to the local tags, judged as the cut judges them, so with signed tags a revoked tag without a valid statement revokes nothing. Fetch the tags before reading.
| Command | With a revoked release |
|---|---|
release list | Marks it, see release list. |
chant components promote --release | Refuses it with release-revoked before anything runs. |
chant approve on its release gate | Refuses with release-revoked, so a gate pending for it never passes. chant approve release <gate> --expire clears the pending fact. |
chant workspace status | Flags each component of an environment that runs it, with the chant components rollback line, and reads its pending gate as superseded. |
release publish
Section titled “release publish”Publishes a cut release to the targets its member declares under release.publish in chant.config.ts (#4027, #4047): npm packages, an image in an OCI registry such as GHCR or Docker Hub, and release assets on GitHub, GitLab or Forgejo. Publish Targets lists the declaration, each target and its credentials. It is meant to run in CI, where the job’s OIDC token gives what it publishes provenance, as npm trusted publishing needs.
chant release publish v1.4.0 # a dry run: prints what would be published wherechant release publish v1.4.0 --yes # publishesAfter a best-effort fetch of chant/lifecycle, it refuses before it publishes anything when:
| Code | When |
|---|---|
release-not-found | No cut names the label or digest in this member. |
release-revoked | The release’s commit is revoked. |
release-unsigned | No cut of the release is signed (#4049). |
release-signature-unverified | No cut’s signature verifies for its actor against .chant/allowed_signers at base, or, under a release gate in identity.gates, the signer is not in the gate’s class. |
publish-provenance-missing | --require-provenance is given, and the run has no OIDC token. |
publish-config-invalid | release.publish in chant.config.ts does not validate. |
release-head-mismatch | A target is declared, and the checkout’s HEAD is not the release’s commit. |
release-worktree-dirty | A target is declared, and the tree has a change or an untracked file git does not ignore. |
The last two hold because a target uploads files from the checkout it runs in, which are the release’s only at its commit. A CI job on the release’s tag passes both. A publish with no target reads nothing and checks neither.
A publish takes only a release signed by a key the signers file lists, so cut it with --sign (see Who may cut). --allow-unsigned publishes a release with no signature that verifies anyway. It prints a warning first, and release verify then fails for what was published. Under a release gate in identity.gates, --allow-unsigned changes nothing: the gate publishes only a signed release.
It then checks whether the run has an OIDC token. In GitHub Actions that means ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, which a job with permissions: id-token: write gets. In GitLab CI it means an ID token named SIGSTORE_ID_TOKEN or NPM_ID_TOKEN under the job’s id_tokens. Without one, as on a workstation, it prints a warning on stderr before it publishes anything: what it publishes carries no OIDC provenance. The warning stops the publish only under --require-provenance, which refuses with publish-provenance-missing. With a token, the npm target passes --provenance, and after the reveal runs npm audit signatures over what it published.
Next it plans every target: each reads the files it would upload, checks a file the release records as an artifact against the release’s digest, and finds its credentials. A target that can’t stops the publish with its own code, such as publish-file-missing, publish-artifact-digest-mismatch or publish-credentials-missing, before anything is sent.
Without --yes the publish is a dry run. It prints what each target would publish and where, and uploads nothing:
Dry run: publish v1.4.0 (jcs1-sha256:...) from commit 3f2a9c1d0b7e signed by alice@example.comWhat would be published where: npm (2 packages), with --provenance: @acme/core@1.4.0 to the registry npm's config names, dist-tag next, then latest, carrying sbom.spdx.json @acme/cli@1.4.0 to the registry npm's config names, dist-tag next, then latest, carrying sbom.spdx.json oci ghcr.io/acme/api: dist/api.oci.tar (tag v1.4.0) to ghcr.io/acme/api@sha256:... sbom.spdx.json (SBOM) to ghcr.io/acme/api@sha256:..., a referrer of the image release.intoto.json (provenance) to ghcr.io/acme/api@sha256:..., a referrer of the imageDry run: nothing was published. Run again with --yes to publish: chant release publish v1.4.0 --yesWith --yes it publishes in two passes (#4048). First it stages every target in the order declared, where no consumer resolves it yet: npm packages under the next dist-tag, images by digest, forge assets on a draft release. Only when every target has staged does it reveal them in the same order. Revealing moves npm’s latest with the first package declared going first. It also moves the OCI tags and publishes the forge release. It prints each item it published. Each step is appended to _releases/publishes.jsonl on chant/lifecycle and pushed.
A target that fails stops the publish. The error says whether anything is visible yet and which targets staged, and ends with the same command to run again. Run again, the publish reads the record and skips what an earlier run staged or revealed, and a stage asks its registry first and sends nothing that is there. So a resume publishes nothing twice, from the same checkout or from a fresh CI clone of the release’s commit. The dry run says which targets an earlier run staged or published. Staging and resuming has the details. Every line a publish prints, its --json document and its record have their credentials masked.
Each target attaches an SBOM and the release’s provenance statement when it stages, so nothing is visible before they are attached (#4049). An npm package carries its SBOM in the package. An image gets both as OCI referrers, and a forge release gets both as assets. The provenance statement names the release and carries the cut’s signature. SBOMs and provenance has the details.
With no target declared it makes its checks and prints its warning. It then says that nothing was published and exits 0. The publish steps a component declares (publish-image, publish-artifact, publish-asset, load-image-on-host) are deploy steps. They run in each environment’s deploy of the release, chant components promote --release, and not here.
The document --json gives for a publish holds dryRun and the release’s digest, label and commit. Beside them sit signature (status, actor, key, reason), provenance (oidc, ci, reason), the warnings, the targets (each kind, name, destination, its progress from earlier runs, none, staged or revealed, and its planned items), what was published (each target, destination and items; empty in a dry run) and the release’s environments. A dry run adds next, and a publish adds resumed, the targets an earlier run had staged or revealed, each with its target and step. The warning is printed on stderr in either form.
| Flag | Meaning |
|---|---|
--yes | Publish. Without it the command is a dry run. |
--allow-unsigned | Publish a release with no signature that verifies, with a warning. Never overrides a release gate. |
--require-provenance | Refuse a run with no OIDC token, where it would warn. A CI job that must give npm provenance passes it, so a misconfigured job fails instead of publishing without. |
--json | Print the document described above. |
release verify
Section titled “release verify”Checks that a release was signed by a key .chant/allowed_signers lists (#4049). It reads the release and its cuts from the local chant/lifecycle branch, and the signers file at the release’s own commit, with its signed history, as chant workspace evidence verify reads the policy at a revision. It fetches nothing and needs no key, so fetch chant/lifecycle and the release’s commit first.
git fetch origin chant/lifecycle:chant/lifecyclechant release verify v1.4.0A release verifies when its stored file is the digest of what it holds, and a cut of it carries a signature in the chant-release namespace that verifies for the cut’s actor. When the declaration at the release’s commit names a release gate with a class, the actor must be in it too. Because the signers file is read at the release’s commit, a key added later does not make an older release verify, and a key removed later does not unmake it. release list judges cuts against the signers file at base instead, which is the policy now.
It exits 0 when the release verifies and prints the signer on stderr:
cut by alice at 2026-10-17T10:00:00.000Z: verified (SHA256:...)Verified v1.4.0 (jcs1-sha256:...): signed by alice, checked against .chant/allowed_signers at 3f2a9c1d0b7eIt exits 1 with one of these codes:
| Code | When |
|---|---|
release-unsigned | No cut of the release is signed. |
release-signature-unverified | No cut’s signature verifies against the signers file at the release’s commit, such as one signed by a key the file does not list. |
release-digest-mismatch | The stored release is not the digest of what it holds, so it was changed after the cut. |
release-not-found | No cut names the label or digest in this member. |
release-commit-missing | The release’s commit is not in this clone. |
trust-policy-unreadable | The signers file or its history at the commit can’t be read. |
--json prints releaseDigest, label, member, commit, signers (path, at, active), digestMatches, status (verified, unsigned or unverified), verifiedBy (actor, key, or null), reason and each cut with its signatureCheck. A release that does not verify adds error with its code and message.
release export
Section titled “release export”Writes the files a cut recorded for one environment, each at the path it was compiled to, under -o <dir> (the member’s directory by default). Each file’s bytes are read from chant/lifecycle, after a best-effort fetch, and checked against the target’s digest first. A missing or changed file stops the export before anything is written.
chant release export v1.4.0 --env prodkubectl apply -f dist/build.yamlThis is how a project with no components deploys what it cut. chant components promote --release deploys components, and refuses such a cut with a pointer to this command. The project’s own deploy, an Op such as chant run deploy or a CI job, then reads the exported files. An Op whose first phase runs the build again would overwrite them, so drop that phase when it deploys exported files. --json prints the digest and label with the environment and the directory written to. It also prints the target’s templates and params.
release list
Section titled “release list”Lists every release of the member, newest cut first. Each line starts with the label and the digest. The commit comes next, then the newest cut’s signature (signed by <actor>, unsigned or unverified), then where the release runs. An unverified signature’s reason goes to stderr. A revoked release’s line ends with revoked. Why it was revoked goes to stderr.
--json prints a read contract document, releases.schema.json. Each entry holds the fields of the stored file. It adds label and cuts, and environments says where it runs: for each environment, the components whose latest deploy record there names its digest in releaseDigest. Each entry has revoked, which is null unless the release’s commit is revoked. When it is, revoked holds the commit and the revoked tag as chant ci list shows it. It is also null when the tags can’t be judged. Each cut carries signatureCheck, { status, key, reason }. Its status is verified, unsigned or unverified, and key is the fingerprint in the signature. An environment shows up once chant components promote --release has deployed to it. A stored file or a cut line that can’t be read is left out and counted under reason, with the code ledger-malformed. A read that fails outright exits 1 with ledger-unreadable, or not-a-git-repository outside git. The list reads the local branch and never fetches.
Joining a release to its commit, gates and deploys
Section titled “Joining a release to its commit, gates and deploys”The records name one another through the release digest (#4043). From a commit, a release, a release gate or a deploy record, the others are one lookup away:
| From | To | Through |
|---|---|---|
| commit | releases | the commit of each _releases/<releaseDigest>.json |
| release | gates | releaseDigest on the facts of _gates/release.jsonl |
| release | deploy records | releaseDigest on the records of every <env>/releases.jsonl, or for a record with none, the release gate resolution its admission names |
| deploy record | release | its releaseDigest, or for a record with none but an admission of op release, the resolution that admission names |
A deploy record’s env with its releaseDigest names the target. components promote --release and run --components --release write the field, and components rollback and components redeploy copy it from the record they restore. workspace status lists it on each item as releaseDigest, with releaseLabel, the label the cut gave.
joinRelease in lifecycle/release-join.ts does the walk for a library reader. It takes { commit }, { commits }, { release }, { deploy } or { gate } and returns what it found, each with its label, commit, cuts, gates and deploys. A commit can have several. { commits } joins every release cut from any of several commits and reads the ledgers once (#4023). A starting point that names nothing returns an empty list and a reason. workspace graph --intent uses it for the why chain, from a piece of code to the releases and deploys that shipped it.