Skip to main content

Immutable releases and release attestation

A published release is a promise: these files, built from this commit, under this tag. Without immutability that promise can be quietly broken after the fact: a tag re-pointed at another commit, an asset swapped for a different binary, a release deleted and re-created under the same name. Immutable releases make each of those impossible, and a release attestation lets anyone check, on their own machine, that what they downloaded is what was published.

What gets frozen

When immutable releases are on, a release is frozen the moment it is published. From then on:

ChangeAllowed?
Edit the title, the notes, the pre-release flagYes
Change the tag name or the target commitNo
Move the tag with git push --force, or delete it with git push --deleteNo
Turn the release back into a draftNo
Add, rename, replace or delete an assetNo
Delete the releaseYes, but its tag name can never be used again in that repository

Every surface enforces the same rule: the web UI, the REST API (HTTP 422 with a message that starts immutable release:), and git itself (the push is declined by the pre-receive hook with a message naming the tag).

Freezing is forward-only. Turning the setting on freezes nothing that was already published, and turning it off later unfreezes nothing. If a frozen release genuinely has to change, contact support: support can lift the freeze of that one release, with a written reason that appears in your audit log.

Publish assets before you publish the release

A published immutable release accepts no new assets, so the order matters. Create the release as a draft, upload the assets, then publish it:

# 1. draft
curl -sS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"tag_name":"v1.2.3","name":"v1.2.3","draft":true}' \
  https://frem.sh/api/v1/repos/acme/web/releases
# 2. assets (repeat per file)
curl -sS -X POST -H "Authorization: token $TOKEN" -F attachment=@dist/web-linux-amd64.tar.gz \
  "https://frem.sh/api/v1/repos/acme/web/releases/$RELEASE_ID/assets?name=web-linux-amd64.tar.gz"
# 3. publish — this is the moment it freezes
curl -sS -X PATCH -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"draft":false}' https://frem.sh/api/v1/repos/acme/web/releases/$RELEASE_ID

Release tools that create a published release and upload afterwards will fail at the upload with 422. Assets that are only external links cannot be part of an immutable release, because there are no stored bytes to take a digest of; publishing one is refused.

Every asset now carries its digest in the API, as GitHub’s does: "digest": "sha256:<hex>", and an immutable release reports "immutable": true.

Container tags

The same policy can freeze container image tags in the registry:

ModeFrozen
off (default)nothing: every tag can be overwritten
semverversion tags: v1.2.3, 1.2.3, 1.2.3-rc.1; latest, 1, 1.2, main, sha-… stay movable
allevery tag except the movable ones you list (latest, edge-*, …)

A push of a frozen tag that points it at a different digest is refused with the registry error DENIED (HTTP 403). Pushing the same image again is allowed, so a re-run CI job does not break. Deleting a frozen tag is refused, and package cleanup rules never select one; frozen tags still count towards your storage quota. The signature and attestation tags cosign writes (sha256-<digest>.sig, .att, .sbom) are always movable. Container tags are evaluated against the current policy, so turning the mode off is how you move or delete one deliberately.

Turning it on

Org owners: Security → Immutable releases in the console (/<org>/_admin/code-security/releases), or the API:

curl -sS -X PUT -H "Authorization: Bearer $FFP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"releases":true,"allow_repo_opt_out":false,"oci_mode":"semver","oci_mutable_tags":["latest"]}' \
  https://frem.sh/_app/api/v1/orgs/acme/code-security/immutability

A repository can override the organisation: on always freezes releases there, even while the organisation default is off; off opts out, and is honoured only when the organisation allows opting out; inherit follows the organisation.

curl -sS -X PUT -H "Authorization: Bearer $FFP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"releases":"on"}' https://frem.sh/_app/api/v1/orgs/acme/code-security/immutability/repos/web

Pull mirrors are never frozen: their tags belong to the repository they mirror.

The release attestation

For every immutable release fremforge signs one in-toto statement of type https://in-toto.io/attestation/release/v0.1. Its subjects are:

  • every asset, by name and SHA-256 (computed while the file was uploaded);
  • the source, git+https://frem.sh/<org>/<repo>@refs/tags/<tag>, by gitCommit.

The statement is taken from a manifest Forgejo writes in the same transaction that publishes the release, and re-checked against the live release before it is signed. It is signed with the same keys as every other fremforge attestation (an Ed25519 DSSE signature, plus a keyless Sigstore bundle from fremforge’s own Fulcio and timestamp authority) and entered into the transparency log. The attestation is served by the API and the CLI; it is never attached to the release itself.

Signing never delays a publish. A release is published first and signed shortly after, so for a short while its attestation is pending. Pending is not a pass: verification says PENDING and exits with its own code.

Verifying a release

fremforge release verify acme/web v1.2.3 ./web-linux-amd64.tar.gz ./checksums.txt

It fetches the attestation, verifies the signature against the published trust root (https://www.frem.sh/.well-known/slsa-trust-root.json), checks the statement is for this repository and tag, hashes every file you name on your machine and compares it with the signed digest, and checks the envelope is in the transparency log. A file the release does not name fails.

Exit codeMeaning
0PASS
1FAIL (signature, statement, a file’s digest, the log, or the attestation was refused)
2usage error
3PENDING: published, not signed yet; re-run shortly

Offline, with no network at all, given the envelope and a copy of the trust root:

fremforge release verify acme/web v1.2.3 ./web-linux-amd64.tar.gz \
  --offline --envelope=release.intoto.jsonl --trust-root=slsa-trust-root.json

Offline verification checks the signature, the statement and your files; it cannot check the attestation’s status or the transparency log, and says so.

The raw data is at GET /_app/api/v1/orgs/{org}/repos/{repo}/releases/{tag}/attestation (findings:read): status (pending, signed, failed, unfrozen), the subjects, the manifest and, once signed, short-lived URLs for the DSSE envelope and the Sigstore bundle. Only signed carries an envelope.

Not covered

  • The auto-generated source archives (the .zip and .tar.gz Forgejo offers for every tag) are not attested: their bytes are not stable across git versions. The source subject names the tag’s commit instead; verify a checkout with git rev-parse v1.2.3^{commit}.
  • Releases published before immutability was turned on are not frozen and have no release attestation.

Errors

SurfaceShape
Forgejo REST API422 {"message": "immutable release: …"}
git pushremote: … pre-receive hook declined with the reason
Container registry403 {"errors":[{"code":"DENIED", …}]}
fremforge APInot-enabled, invalid-value, invalid-repo, forgejo-error, not-found