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:
| Change | Allowed? |
|---|---|
| Edit the title, the notes, the pre-release flag | Yes |
| Change the tag name or the target commit | No |
Move the tag with git push --force, or delete it with git push --delete | No |
| Turn the release back into a draft | No |
| Add, rename, replace or delete an asset | No |
| Delete the release | Yes, 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_IDRelease 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:
| Mode | Frozen |
|---|---|
off (default) | nothing: every tag can be overwritten |
semver | version tags: v1.2.3, 1.2.3, 1.2.3-rc.1; latest, 1, 1.2, main, sha-… stay movable |
all | every 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/immutabilityA 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/webPull 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>, bygitCommit.
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.txtIt 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 code | Meaning |
|---|---|
0 | PASS |
1 | FAIL (signature, statement, a file’s digest, the log, or the attestation was refused) |
2 | usage error |
3 | PENDING: 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.jsonOffline 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
.zipand.tar.gzForgejo 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 withgit rev-parse v1.2.3^{commit}. - Releases published before immutability was turned on are not frozen and have no release attestation.
Errors
| Surface | Shape |
|---|---|
| Forgejo REST API | 422 {"message": "immutable release: …"} |
| git push | remote: … pre-receive hook declined with the reason |
| Container registry | 403 {"errors":[{"code":"DENIED", …}]} |
| fremforge API | not-enabled, invalid-value, invalid-repo, forgejo-error, not-found |