Skip to content

Publish Targets

llms.txt lists every page for an agent

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.

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.

FieldMeaning
packagesPackage directories or .tgz files from npm pack in the order to publish them. The first is the set’s core
registryThe registry URL. Default: the one npm’s own config names
accesspublic or restricted for npm’s --access
sbomThe 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.

FieldMeaning
imageAn 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
tagsTags to point at the pushed digest. {label} is the release’s label. The default is ["{label}"] when the release has a label
usernameEnv and passwordEnvThe variables holding the registry user and its password or token. The defaults are OCI_USERNAME and OCI_PASSWORD
sbomAn 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.

FieldMeaning
forgegithub, gitlab or forgejo
repoowner/name on GitHub and Forgejo; the project path or numeric id on GitLab
assetsFiles to attach. Each is named by its file name, which must be unique
apiThe 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
tagThe forge release’s tag. Default: the tag the cut made, release/<label> or release/<member>/<label>
tokenEnvThe variable holding the token. Default: GITHUB_TOKEN on GitHub, GITLAB_TOKEN then CI_JOB_TOKEN on GitLab, FORGEJO_TOKEN on Forgejo
sbomAn 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.

ForgeStageReveal
GitHubFinds the draft release for the tag, or creates one with draft: true at the release’s commit, and uploads each asset to its upload_urlPublishes the draft, and GitHub creates the tag if it was not pushed
ForgejoThe same draft, with a multipart upload of each asset to /repos/{repo}/releases/{id}/assetsPublishes the draft, as GitHub does
GitLabPuts 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 noneCreates 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.

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.

TargetSBOMProvenance in CIProvenance from a workstation
npmA file in each package, since npm keeps no file beside a packagenpm’s own, from --provenance, checked with npm audit signaturesThe release’s signature on chant/lifecycle only
OCIAn OCI referrer of the imageThe provenance statement as a referrer of the imageThe same statement, which says its provenance is the signature
ForgeAn asset of the releaserelease.intoto.json, an asset of the releaseThe same asset, which says its provenance is the signature

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 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 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: write
steps:
- 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 --yes

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"}
FieldMeaning
targetThe 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
stepstaged or revealed
itemsWhat the step published, with each destination and digest
stateWhat the reveal needs that it can’t read from the checkout, such as a draft release’s releaseId
runIdThe 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.

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 masksSuch as
Each credential a target read, and each token a registry handed back for itthe value of OCI_PASSWORD, a GHCR bearer token
The value of each variable CI and registries use for tokensNODE_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 namespasswordEnv: "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.

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.

FieldMeaning
release, labelThe release and its label, or null
releaseTagThe tag the cut made, or null with no label
provenanceWhether the run has an OIDC token, and why not
cutThe cut whose signature the provenance statement carries: actor, label, timestamp, runId and signature. Optional; null when none is known
cwdThe member’s directory
tagA 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
envThe environment credentials are read from
redactorMasks 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.

chant