Publish Targets
chant release publish sends a cut release to the targets its member declares (#4047). This page covers the declaration and each target with its credentials, how a publish stages every target before any becomes visible and resumes after a failure (#4048), the SBOM and provenance each target attaches (#4049), and the contract in packages/core/src/lifecycle/release-publish.ts that a new target implements. The command itself is on chant release.
Declaring targets
Section titled “Declaring targets”Targets are declared under release.publish in the member’s chant.config.ts, a list that the publish runs in order:
export default { environments: ["dev", "prod"], release: { publish: [ { type: "npm", packages: ["packages/core", "packages/cli"], access: "public" }, { type: "oci", image: "dist/api.oci.tar", repository: "ghcr.io/acme/api", usernameEnv: "GITHUB_ACTOR", passwordEnv: "GITHUB_TOKEN" }, { type: "forge", forge: "github", repo: "acme/api", assets: ["dist/api-linux-amd64.tar.gz"] }, ], },};Paths are relative to the member’s directory. A declaration names the environment variables its credentials are read from, and never holds a credential: an unknown key, such as password, fails validation. A block that does not validate stops the publish with publish-config-invalid.
The config is read from the checkout the publish runs in. With at least one target, the publish refuses a checkout whose HEAD is not the release’s commit (release-head-mismatch) or whose tree has a change or an untracked file git does not ignore (release-worktree-dirty). So the config, and every file a target reads, are the release commit’s. A CI job that checks out the release’s tag passes both checks. Build outputs that the job makes before it publishes belong in .gitignore.
A target never builds. It uploads the files that are there. A file that the release records as an artifact of the same kind at the same path must match the release’s digest, or the publish stops with publish-artifact-digest-mismatch.
| Field | Meaning |
|---|---|
packages | Package directories or .tgz files from npm pack in the order to publish them. The first is the set’s core |
registry | The registry URL. Default: the one npm’s own config names |
access | public or restricted for npm’s --access |
sbom | The SBOM file each package carries at its root. Default: the first of sbom.spdx.json, sbom.cdx.json and sbom.cyclonedx.json it holds. false publishes without one |
The stage runs, for each package, npm view <name>@<version> version to ask whether the registry already holds the version. When it doesn’t, it runs npm publish <package> --tag next with the declared --registry and --access, and --provenance when the run has an OIDC token. A publish from GitHub Actions with id-token: write gets npm provenance this way. So does one from GitLab CI with SIGSTORE_ID_TOKEN, and npm trusted publishing works in both. A version under next is not what latest or a range resolves to, so a consumer sees nothing yet. A version already on the registry is not published again. When one package fails, the error names the packages staged before it.
The reveal runs npm dist-tag add <name>@<version> latest for each package in the declared order, with the declared --registry. The first package declared is the set’s core, and its latest moves first, so a consumer that resolves a new package also finds its new core. Order the list core first, then each package after the packages it depends on. This is the order chant’s own releases use (#3903).
A new package is the exception: npm makes its first version latest whatever tag it is published under, so that version is visible as soon as it stages. Moving a dist-tag needs credentials that may change dist-tags. Over OIDC that is “Allow npm dist-tag” on the package’s trusted-publisher record, which is separate from publishing.
With an OIDC token, the reveal then checks what it published. In an empty temporary directory it runs npm install --ignore-scripts --no-audit --no-fund for every package at its version, then npm audit signatures, both with the declared --registry. npm fails the audit when a package’s registry signature or provenance attestation does not verify, and the publish stops with publish-npm-audit-failed. By then latest has moved, so the error says the packages were published. Run again, the publish moves no tag that moved and audits again. A workstation has no token and so no npm provenance to audit, and there the step does not run.
For both steps npm authenticates with its own config. chant reads no npm token and writes no .npmrc. On a workstation that is npm login. In GitHub Actions, actions/setup-node with registry-url writes an .npmrc that reads NODE_AUTH_TOKEN, and trusted publishing needs no token. npm runs with the run’s environment, and chant masks what it prints.
OCI registries
Section titled “OCI registries”| Field | Meaning |
|---|---|
image | An OCI image layout as a directory or as the tar file docker save writes. Its index.json names one image manifest or one index |
repository | <registry host>/<name> like ghcr.io/acme/api |
tags | Tags to point at the pushed digest. {label} is the release’s label. The default is ["{label}"] when the release has a label |
usernameEnv and passwordEnv | The variables holding the registry user and its password or token. The defaults are OCI_USERNAME and OCI_PASSWORD |
sbom | An SPDX or CycloneDX JSON file to attach, relative to the member. Default: one written from the member’s lockfile. false attaches none |
The target speaks the registry HTTP API directly, so it needs no registry client installed. The stage pushes by digest only. It asks whether the registry already holds the image’s manifest digest and sends nothing when it does. Otherwise it uploads each config and layer blob the registry does not hold yet, checking each against its digest first, then puts each manifest by its digest, then the index when there is one. It then pushes the SBOM and the provenance statement as referrers of the image, each by its digest and only when the registry does not hold it. A consumer that pulls by tag sees none of it. The reveal points each tag at that digest. A Basic challenge takes the credentials as they are. A Bearer challenge, as GHCR and Docker Hub send, exchanges them for a token at the realm it names.
With neither variable set, the target reads the registry’s auths entry from the docker config file ($DOCKER_CONFIG/config.json, else ~/.docker/config.json) that docker login writes. It does not run a credential helper (credsStore). With none of these, the push is anonymous.
docker.io is reached at registry-1.docker.io, and a one-part Docker Hub name is under library/. A host of localhost, 127.0.0.1 or [::1] is reached over http, any other over https. Pushing an image to a cloud registry per environment is still the publish-image deploy step’s job (Build Archive); this target is for registries that publish the release itself.
Forge release assets
Section titled “Forge release assets”| Field | Meaning |
|---|---|
forge | github, gitlab or forgejo |
repo | owner/name on GitHub and Forgejo; the project path or numeric id on GitLab |
assets | Files to attach. Each is named by its file name, which must be unique |
api | The API root. Default: $GITHUB_API_URL, else https://api.github.com on GitHub; $CI_API_V4_URL, else https://gitlab.com/api/v4 on GitLab; $GITHUB_API_URL on Forgejo, which has no other default |
tag | The forge release’s tag. Default: the tag the cut made, release/<label> or release/<member>/<label> |
tokenEnv | The variable holding the token. Default: GITHUB_TOKEN on GitHub, GITLAB_TOKEN then CI_JOB_TOKEN on GitLab, FORGEJO_TOKEN on Forgejo |
sbom | An SPDX or CycloneDX JSON file to attach, relative to the member. Default: sbom.spdx.json written from the member’s lockfile. false attaches none |
A release with no label and no declared tag is refused (publish-forge-no-tag). The SBOM and release.intoto.json, the provenance statement, go with the declared assets as two more, so their names must not be taken by an asset. The stage puts the assets where no consumer sees them, and the reveal makes the release visible with all of them at once.
| Forge | Stage | Reveal |
|---|---|---|
| GitHub | Finds the draft release for the tag, or creates one with draft: true at the release’s commit, and uploads each asset to its upload_url | Publishes the draft, and GitHub creates the tag if it was not pushed |
| Forgejo | The same draft, with a multipart upload of each asset to /repos/{repo}/releases/{id}/assets | Publishes the draft, as GitHub does |
| GitLab | Puts each asset in the project’s generic package registry as package release at the label’s version. GitLab has no draft release, so it makes none | Creates the release at the commit with a link to each asset, in one request |
An asset already on the draft with the same name and a matching digest is left as it is, so a stage run again uploads only what is missing. One whose digest differs is refused (publish-forge-asset-exists), and so is one on Forgejo, which reports no digest. A release already published for the tag is refused (publish-forge-release-published), since assets uploaded to it would show one at a time. The exception is a published release that already holds every asset, which is what an earlier publish leaves; the stage then does nothing.
SBOMs and provenance
Section titled “SBOMs and provenance”Each target attaches an SBOM and the release’s provenance statement to what it publishes (#4049). Both are attached in the stage, so nothing is visible before they are.
| Target | SBOM | Provenance in CI | Provenance from a workstation |
|---|---|---|---|
| npm | A file in each package, since npm keeps no file beside a package | npm’s own, from --provenance, checked with npm audit signatures | The release’s signature on chant/lifecycle only |
| OCI | An OCI referrer of the image | The provenance statement as a referrer of the image | The same statement, which says its provenance is the signature |
| Forge | An asset of the release | release.intoto.json, an asset of the release | The same asset, which says its provenance is the signature |
The SBOM
Section titled “The SBOM”An npm package must carry its SBOM at its root, in the directory or under package/ in the .tgz, such as one that npm sbom --sbom-format spdx > packages/core/sbom.spdx.json writes before packing. Keep it in what npm packs: a package with a files list names it there. A package with none is refused with publish-sbom-missing in the plan, before anything is sent.
The OCI and forge targets attach the file sbom names. Without one they write an SPDX document from the nearest package-lock.json or pom.xml, in the member’s directory or above it inside the repository, with the hermetic writer the generate-sbom capability uses. It lists the declared dependencies and not an image’s base layers, so an image built with a layer scan (BuildKit’s --sbom, syft) should name that scan’s file in sbom. The document is dated with the cut’s time, so a publish run again writes the same bytes. With neither a declared file nor a lockfile, the target is refused with publish-sbom-missing. A file that is not SPDX or CycloneDX JSON is refused with publish-sbom-invalid. sbom: false publishes without an SBOM.
The provenance statement
Section titled “The provenance statement”The provenance statement is an in-toto Statement v1 with the media type application/vnd.in-toto+json. Its subjects are the digests of what the target publishes: the image’s manifest digest, or each forge asset’s. The predicate type is https://intentius.io/chant/release/v1, and a predicate looks like this.
{ "releaseDigest": "jcs1-sha256:...", "label": "v1.4.0", "member": null, "commit": "3f2a9c1d0b7e...", "cut": { "actor": "alice", "label": "v1.4.0", "timestamp": "2026-10-17T10:00:00.000Z", "runId": "local-1760695200000", "namespace": "chant-release", "signature": "-----BEGIN SSH SIGNATURE-----..." }, "provenance": { "kind": "oidc", "ci": "github" }}cut is the cut whose signature verified, and signature is the armored ssh signature over the release digest, the label, the actor and the time, one per line. Anyone with the repository checks it with chant release verify, offline, against the signers file at the release’s commit. provenance.kind is oidc when the run had an OIDC token and signature when it had none, as on a workstation. The statement itself is not signed with the OIDC token: in CI, npm’s provenance is the one a registry records and signs, and for images, an attestation signed with the job’s token is the CI’s to add, such as actions/attest-build-provenance. Every value comes from the release and its cut and none from the clock. The statement is then the same bytes on every run, and a resumed stage finds what an earlier run attached.
A CI job that checks provenance
Section titled “A CI job that checks provenance”A CI job that publishes passes --require-provenance, so a job whose OIDC token is missing fails instead of publishing without provenance. The npm target runs npm audit signatures after its reveal:
permissions: contents: read id-token: writesteps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: actions/setup-node@v4 with: { node-version: 22, registry-url: https://registry.npmjs.org } - run: npm ci && npm run build - run: npx chant release verify "${GITHUB_REF_NAME#release/}" - run: npx chant release publish "${GITHUB_REF_NAME#release/}" --require-provenance --yesStaging and resuming
Section titled “Staging and resuming”A publish with --yes is all or nothing for a consumer. It stages every target in the order declared, and only when all of them have staged does it reveal them, in the same order. A failure while staging leaves nothing a consumer resolves changed, apart from the first version of a new npm package. A failure while revealing leaves the targets before it visible and the rest staged.
Each step is recorded as one line in _releases/publishes.jsonl on chant/lifecycle, beside cuts.jsonl and under the member’s prefix. The file is append-only, and each line is pushed as soon as it is written. This line, version 1, is what chant 0.124.29 and newer write after staging an npm target:
{"version":1,"releaseDigest":"jcs1-sha256:...","label":"v1.4.0","target":"npm:3fa2b1c4d5e6f708","kind":"npm","name":"npm (2 packages)","step":"staged","items":[{"name":"@acme/core@1.4.0","destination":"..."}],"state":{},"runId":"8123456789","timestamp":"2026-10-17T10:00:00.000Z"}| Field | Meaning |
|---|---|
target | The target’s key: its kind and the first 16 hex digits of the SHA-256 of its declaration. A declaration holds variable names and never a credential |
step | staged or revealed |
items | What the step published, with each destination and digest |
state | What the reveal needs that it can’t read from the checkout, such as a draft release’s releaseId |
runId | The CI run, or a local-<epoch ms> id on a workstation |
Run again after a failure, the publish reads the record first. A target with a revealed line is skipped. A target with a staged line is revealed without staging again. The rest stage, and each stage asks its destination before it sends anything: an npm version on the registry, an image digest the registry holds, or an asset on the draft is not sent twice. So a resume publishes nothing twice, and that holds even when a record line was not written or not pushed, only slower. The record is pushed so that a CI job on a fresh clone of the release’s commit resumes where the failed job stopped, since chant release publish fetches chant/lifecycle first. A push that fails is a warning, not a failure.
The record is on the branch rather than read from the registries alone, because a registry can say that a version or a digest is there but not that it belongs to this release’s publish, or that every target of the set has staged, which is when the reveals may start. Each line is redacted before it is written, and holds names, destinations, digests and draft ids, never a credential.
Credentials
Section titled “Credentials”Every credential comes from the environment of the run, which is the shell on a workstation and the job’s secrets in CI. chant holds it in memory for the requests that need it and never writes it to chant/lifecycle or to a file it keeps. Each line a publish prints passes through a redactor first, and so do the --json document and each error.
| The redactor masks | Such as |
|---|---|
| Each credential a target read, and each token a registry handed back for it | the value of OCI_PASSWORD, a GHCR bearer token |
| The value of each variable CI and registries use for tokens | NODE_AUTH_TOKEN, NPM_TOKEN, NPM_ID_TOKEN, SIGSTORE_ID_TOKEN, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN, GITHUB_TOKEN, GH_TOKEN, GITLAB_TOKEN, CI_JOB_TOKEN, FORGEJO_TOKEN, GITEA_TOKEN, OCI_PASSWORD, DOCKER_PASSWORD |
| The value of each variable a declaration names | passwordEnv: "GHCR_PAT" |
| A token written in the forms npm and HTTP headers use | _authToken=..., Authorization: Bearer ..., PRIVATE-TOKEN: ... |
A target that needs a credential and finds none stops the publish before anything is sent, with publish-credentials-missing.
The target contract
Section titled “The target contract”A target is an object with this shape, from packages/core/src/lifecycle/release-publish.ts:
interface PublishTarget { kind: "npm" | "oci" | "forge"; name: string; // for messages, such as "npm (2 packages)" destination: string; // printed before it runs credentialEnv: string[]; // variables it reads credentials from, masked from the start key: string; // names the declaration across runs, for the publish record plan(ctx: PublishContext): Promise<PublishItem[]>; stage(ctx: PublishContext): Promise<StagedTarget>; reveal(ctx: PublishContext, staged: StagedTarget): Promise<PublishedTarget>;}plan reads what the target would publish and checks that it can: the files are there, they match the release’s digests, the credentials are set. It uploads nothing and sends no request that changes anything. The publish plans every target before any of them stages, and the dry run prints what plan returns.
stage puts every item where the target publishes but where no consumer resolves it yet. It asks the destination first, sends nothing already there, and returns such an item with present: true. The StagedTarget that comes back holds the items and a state of strings the reveal needs. The record keeps that state, so it must never hold a credential. reveal is the one last step that makes the staged items visible, given what stage returned in this run or in an earlier one. Run again, it must move nothing that already moved. All three throw PublishTargetError with a code, a message and an optional fix. A failure part way names what was already staged or revealed.
All three are given a PublishContext with these fields.
| Field | Meaning |
|---|---|
release, label | The release and its label, or null |
releaseTag | The tag the cut made, or null with no label |
provenance | Whether the run has an OIDC token, and why not |
cut | The cut whose signature the provenance statement carries: actor, label, timestamp, runId and signature. Optional; null when none is known |
cwd | The member’s directory |
tag | A tag for the reveal to move in place of the target’s own (npm’s latest, the OCI tags), or null. Staging never uses it |
env | The environment credentials are read from |
redactor | Masks credentials. A target adds each credential and token it reads |
log(line) | Prints a line of progress, redacted |
The targets take their dependencies through PublishDeps, which holds the fetch the OCI and forge targets use and the npm executable. In the tests npm runs as a fake binary that keeps a registry with dist-tags in a file, the OCI target pushes to a registry fake that the test starts on 127.0.0.1, and the forge targets call a forge fake with draft releases.
Adding a kind of target takes three changes. Its schema joins the declaration union in lifecycle/publish-declaration.ts, and a factory in a module of its own returns its PublishTarget. Then createPublishTargets in lifecycle/publish-targets.ts gets a case that calls the factory.
A target attaches its SBOM and provenance with the helpers in lifecycle/publish-attachments.ts: memberSbom for an SBOM beside what it publishes, packageSbom for one inside a package, and releaseProvenance for the statement. Each attached file comes back as a PublishItem with attachment set to sbom or provenance.