Releases immuables et attestation de release
Une release publiée est une promesse : ces fichiers, construits à partir de ce commit, sous ce tag. Sans immuabilité, cette promesse peut être rompue discrètement après coup : un tag redirigé vers un autre commit, un artefact remplacé par un autre binaire, une release supprimée puis recréée sous le même nom. Les releases immuables rendent chacune de ces opérations impossible, et une attestation de release permet à quiconque de vérifier, sur sa propre machine, que ce qu’il a téléchargé correspond à ce qui a été publié.
Ce qui est figé
Lorsque les releases immuables sont activées, une release est figée dès sa publication. À partir de ce moment :
| Modification | Autorisée ? |
|---|---|
| Modifier le titre, les notes, l’indicateur de pré-release | Oui |
| Changer le nom du tag ou le commit cible | Non |
Déplacer le tag avec git push --force, ou le supprimer avec git push --delete | Non |
| Repasser la release en brouillon | Non |
| Ajouter, renommer, remplacer ou supprimer un artefact | Non |
| Supprimer la release | Oui, mais son nom de tag ne pourra plus jamais être réutilisé dans ce dépôt |
Chaque surface applique la même règle : l’interface web, l’API REST (HTTP 422 avec un message commençant par immutable release:) et git lui-même (le push est refusé par le hook pre-receive avec un message qui nomme le tag).
Le gel ne vaut que pour l’avenir. Activer le paramètre ne fige rien de ce qui a déjà été publié, et le désactiver plus tard ne défige rien. Si une release figée doit vraiment changer, contactez le support : le support peut lever le gel de cette seule release, avec une raison écrite qui apparaît dans votre journal d’audit.
Publiez les artefacts avant de publier la release
Une release immuable publiée n’accepte plus de nouveaux artefacts, l’ordre compte donc. Créez la release en tant que brouillon, téléversez les artefacts, puis publiez-la :
# 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_IDLes outils de release qui créent une release publiée puis téléversent ensuite échoueront au téléversement avec 422. Les artefacts qui ne sont que des liens externes ne peuvent pas faire partie d’une release immuable, car il n’existe aucun octet stocké dont calculer le condensé ; leur publication est refusée.
Chaque artefact porte désormais son condensé dans l’API, comme chez GitHub : "digest": "sha256:<hex>", et une release immuable indique "immutable": true.
Tags de conteneur
La même politique peut figer les tags d’images de conteneur dans le registre :
| Mode | Figé |
|---|---|
off (par défaut) | rien : tout tag peut être écrasé |
semver | les tags de version : v1.2.3, 1.2.3, 1.2.3-rc.1 ; latest, 1, 1.2, main, sha-… restent déplaçables |
all | tous les tags sauf les tags déplaçables que vous listez (latest, edge-*, …) |
Un push d’un tag figé qui le ferait pointer vers un condensé différent est refusé avec l’erreur de registre DENIED (HTTP 403). Pousser à nouveau la même image est autorisé, de sorte qu’un job CI relancé ne casse pas. La suppression d’un tag figé est refusée, et les règles de nettoyage des paquets n’en sélectionnent jamais ; les tags figés comptent toujours dans votre quota de stockage. Les tags de signature et d’attestation qu’écrit cosign (sha256-<digest>.sig, .att, .sbom) sont toujours déplaçables. Les tags de conteneur sont évalués selon la politique en vigueur : désactiver le mode est donc la façon de déplacer ou supprimer délibérément l’un d’eux.
Paquets
Les versions de paquets publiées sont immuables sur fremforge : npm, Maven, PyPI, NuGet, Cargo, Go, Helm, Conan, Composer, RubyGems, Pub, Swift, Conda, CRAN, Debian, RPM, Alpine, Arch, Chef, Vagrant et paquets génériques. C’est activé pour chaque organisation par défaut. Les conteneurs suivent plutôt le mode des tags de conteneur ci-dessus.
| Modification | Autorisée ? |
|---|---|
| Téléverser une nouvelle version | Oui |
| Ajouter un nouveau fichier à une version existante (une autre wheel pour une plateforme, un jar Maven avec classifier) | Oui |
| Téléverser à nouveau un fichier avec les mêmes octets (un job CI relancé) | Oui, sans effet |
| Remplacer un fichier d’une version publiée par des octets différents | Non |
| Supprimer une version, ou l’un de ses fichiers | Oui |
| Publier à nouveau une version supprimée, avec n’importe quels octets | Non : le nom de version reste retiré |
| Téléverser à nouveau un fichier supprimé avec des octets différents | Non |
Un téléversement refusé renvoie HTTP 409 avec un message commençant par immutable package:, et l’en-tête de réponse X-Fremforge-Immutable indique pourquoi : overwrite, reupload ou retired. Chaque refus est écrit dans votre journal d’audit sous package.immutable.refused, avec le paquet, la version, le fichier, les deux condensés SHA-256 et l’auteur de la tentative. Il apparaît aussi dans le journal d’accès au registre comme un téléversement bloqué.
La plupart des registres refusent déjà de publier deux fois une version. Ce qui change ici :
- Helm et Conan permettaient d’écraser une version sur place.
- Dans tous les registres, une version pouvait être supprimée puis republiée avec un contenu différent. Ces deux cas sont désormais refusés.
Toujours modifiables :
- Les versions Maven
-SNAPSHOTet les fichiersmaven-metadata.xml, réécrits par conception. - Les fichiers d’index de dépôt que maintiennent les registres Debian, RPM, Alpine et Arch.
Pour garder d’autres versions modifiables, par exemple un paquet générique utilisé comme cache de CI, listez-les comme exemptions sous la forme [<type>:][<name>@]<version>, où * et ? servent de jokers :
| Entrée | Garde modifiable |
|---|---|
generic:ci-cache@* | toutes les versions du paquet générique ci-cache |
npm:@acme/ui@0.0.0-dev* | les builds de développement d’un paquet npm |
*-nightly | les versions *-nightly de tous les paquets |
Une organisation peut aussi désactiver entièrement l’immuabilité des paquets, ou revenir à la valeur par défaut de la plateforme (inherit). Le paramètre est évalué au moment d’une écriture, et les noms des versions supprimées pendant qu’il était désactivé restent mémorisés. L’activer protège donc aussi les versions supprimées auparavant.
Activation
Propriétaires de l’organisation : Sécurité › Politiques › Releases immuables dans la console (/<org>/_admin/security/policies/immutable-releases), ou l’API avec un jeton portant policy:write (la lecture des paramètres nécessite 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/immutabilityL’immuabilité des paquets utilise le même endpoint. packages vaut inherit (suivre la valeur par défaut de la plateforme, qui est activée), on ou off, et package_mutable_versions liste les exemptions. La réponse indique aussi 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/immutabilityUn dépôt peut surcharger l’organisation : on y fige toujours les releases, même lorsque la valeur par défaut de l’organisation est désactivée ; off est un désengagement, respecté uniquement si l’organisation l’autorise ; inherit suit l’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/webLes miroirs (pull mirrors) ne sont jamais figés : leurs tags appartiennent au dépôt qu’ils reflètent.
L’attestation de release
Pour chaque release immuable, fremforge signe une déclaration in-toto de type https://in-toto.io/attestation/release/v0.1. Ses sujets sont :
- chaque artefact, par nom et SHA-256 (calculé pendant le téléversement du fichier) ;
- la source,
git+https://frem.sh/<org>/<repo>@refs/tags/<tag>, pargitCommit.
La déclaration est tirée d’un manifeste que Forgejo écrit dans la même transaction que celle qui publie la release, et revérifiée par rapport à la release en ligne avant d’être signée. Elle est signée avec les mêmes clés que toutes les autres attestations fremforge (une signature DSSE Ed25519, plus un bundle Sigstore sans clé issu du Fulcio et de l’autorité d’horodatage propres à fremforge) et inscrite dans le journal de transparence. L’attestation est servie par l’API et la CLI ; elle n’est jamais jointe à la release elle-même.
La signature ne retarde jamais une publication. Une release est d’abord publiée puis signée peu après, si bien que pendant un court moment son attestation est en attente. En attente ne signifie pas réussie : la vérification indique PENDING et se termine avec son propre code.
Vérifier une release
fremforge release verify acme/web v1.2.3 ./web-linux-amd64.tar.gz ./checksums.txtLa commande récupère l’attestation, vérifie la signature par rapport à la racine de confiance publiée (https://www.frem.sh/.well-known/slsa-trust-root.json), contrôle que la déclaration concerne bien ce dépôt et ce tag, calcule le condensé de chaque fichier que vous indiquez sur votre machine et le compare au condensé signé, et vérifie que l’enveloppe figure dans le journal de transparence. Un fichier que la release ne mentionne pas échoue.
| Code de sortie | Signification |
|---|---|
0 | PASS |
1 | FAIL (signature, déclaration, condensé d’un fichier, journal, ou attestation refusée) |
2 | erreur d’utilisation |
3 | PENDING : publiée, pas encore signée ; relancez sous peu |
Hors ligne, sans aucun réseau, à partir de l’enveloppe et d’une copie de la racine de confiance :
fremforge release verify acme/web v1.2.3 ./web-linux-amd64.tar.gz \
--offline --envelope=release.intoto.jsonl --trust-root=slsa-trust-root.jsonLa vérification hors ligne contrôle la signature, la déclaration et vos fichiers ; elle ne peut pas vérifier le statut de l’attestation ni le journal de transparence, et l’indique.
Les données brutes se trouvent à GET /_app/api/v1/orgs/{org}/repos/{repo}/releases/{tag}/attestation (findings:read) : status (pending, signed, failed, unfrozen), les sujets, le manifeste et, une fois signée, des URL de courte durée pour l’enveloppe DSSE et le bundle Sigstore. Seul signed comporte une enveloppe.
Non couvert
- Les archives de sources générées automatiquement (les
.zipet.tar.gzque Forgejo propose pour chaque tag) ne sont pas attestées : leurs octets ne sont pas stables d’une version de git à l’autre. Le sujet source désigne plutôt le commit du tag ; vérifiez un checkout avecgit rev-parse v1.2.3^{commit}. - Les releases publiées avant l’activation de l’immuabilité ne sont pas figées et n’ont pas d’attestation de release.
Erreurs
| Surface | Forme |
|---|---|
| API REST de Forgejo | 422 {"message": "immutable release: …"} |
| git push | remote: … pre-receive hook declined avec la raison |
| Registre de conteneurs | 403 {"errors":[{"code":"DENIED", …}]} |
| API fremforge | not-enabled, invalid-value, invalid-repo, forgejo-error, not-found |