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.

Packages

Published package versions are immutable on fremforge: npm, Maven, PyPI, NuGet, Cargo, Go, Helm, Conan, Composer, RubyGems, Pub, Swift, Conda, CRAN, Debian, RPM, Alpine, Arch, Chef, Vagrant and generic packages. This is on for every organisation by default. Containers follow the container tag mode above instead.

ChangeAllowed?
Upload a new versionYes
Add a new file to a version that exists (another wheel for a platform, a Maven classifier jar)Yes
Upload a file again with the same bytes (a re-run CI job)Yes, changes nothing
Replace a file of a published version with different bytesNo
Delete a version, or one of its filesYes
Publish a deleted version again, with any bytesNo: the version name stays retired
Upload a deleted file again with different bytesNo

A refused upload answers HTTP 409 with a message that starts immutable package:, and the X-Fremforge-Immutable response header says why: overwrite, reupload or retired. Each refusal is written to your audit log as package.immutable.refused, with the package, the version, the file, both SHA-256 digests and who tried. It also appears in the registry access log as a blocked upload.

Most registries already refuse to publish a version twice. What changes here:

  • Helm and Conan used to let a version be overwritten in place.
  • In every registry, a version could be deleted and then published again with different content. Both are now refused.

Always mutable:

  • Maven -SNAPSHOT versions and maven-metadata.xml files, which are rewritten by design.
  • The repository index files that Debian, RPM, Alpine and Arch registries maintain.

To keep other versions mutable, for example a generic package used as a CI cache, list them as exemptions in the form [<type>:][<name>@]<version>, where * and ? work as wildcards:

EntryKeeps mutable
generic:ci-cache@*every version of the generic package ci-cache
npm:@acme/ui@0.0.0-dev*development builds of one npm package
*-nightlyevery package’s *-nightly versions

An organisation can also turn package immutability off entirely, or back to the platform default (inherit). The setting is evaluated when a write happens, and the names of versions deleted while it was off are still remembered. So turning it on also protects versions that were deleted earlier.

Turning it on

Org owners: Security → Immutable releases in the console (/<org>/_admin/code-security/releases), or the API with a token carrying policy:write (reading the settings needs findings:read):

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

Package immutability uses the same endpoint. packages is inherit (follow the platform default, which is on), on or off, and package_mutable_versions lists the exemptions. The response also reports packages_effective:

curl -sS -X PUT -H "Authorization: Bearer $FFP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"packages":"inherit","package_mutable_versions":["generic:ci-cache@*"]}' \
  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