Skip to content

Type to search pages.

View .md

Releases and how to verify one

The install command on the front page pipes a script into a shell. That is convenient and it means the release artifacts are the security boundary for everybody who uses this product. This page is how you stop taking our word for it.

Three checks are available, and they answer different questions:

Check Question it answers
Checksum Did the file arrive intact?
Signature Did we publish it?
Rebuild Was it built from the source it claims?

The first is the weakest and the fastest. The third is the strongest and takes a few minutes. Most people should do the first two.

Each tag publishes four archives, one per platform, plus the files you check them with.

File What it is
antifailure_<version>_<os>_<arch>.tar.gz The af binary, the agent runner’s source, the licence and the README
checksums.txt The SHA256 of every archive
checksums.txt.sigstore.json A signature over checksums.txt, with the certificate that made it
sbom.spdx.json An SPDX bill of materials, read out of the built binaries
sbom.spdx.json.sigstore.json A signature over the bill of materials
THIRD_PARTY_NOTICES.md Attribution, generated from what is actually linked, as the union over all four platforms

Only checksums.txt is signed rather than each archive. That is deliberate. checksums.txt names every archive by its hash, so one signature covers all of them, and checking it is two commands instead of eight. Eight things to check get checked zero times.

The installer does this for you and refuses to install a file that does not match. If you downloaded an archive by hand:

Terminal window
sha256sum --check --ignore-missing checksums.txt

On macOS, shasum -a 256 -c --ignore-missing checksums.txt.

This proves the file is not corrupt. It proves nothing about who wrote checksums.txt, which is what the signature is for.

Everything on this page works from v1.0.0 onwards. v0.1.0 and v0.1.1 were built before the signing and the reproducible archives existed, so they carry no .sigstore.json bundle and rebuilding them does not produce the bytes that were published. A release that ran these steps carries checksums.txt.sigstore.json and sbom.spdx.json; a release that carries neither did not, and that is a thing you can check rather than take on trust.

Install cosign. The identity is long and you need it three times, so name it once:

Terminal window
TAG=v1.3.0
REPO=antifailure/antifailure
WORKFLOW=.github/workflows/release.yml
IDENTITY="https://github.com/$REPO/$WORKFLOW@refs/tags/$TAG"
ISSUER="https://token.actions.githubusercontent.com"

Then check the checksums:

Terminal window
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
checksums.txt

Verified OK means the file is the one that was signed.

There is no public key to fetch, because there is no signing key. The release workflow asks GitHub for a short lived identity token, proves to Sigstore that this workflow in this repository is running, and gets a certificate that expires almost immediately. Nothing is stored, so there is nothing to leak and nothing to rotate.

--certificate-identity is the part that makes this mean anything. Without it you would be checking that somebody signed the file, which anybody can do. With it you are checking that this workflow, in this repository, at this tag signed it. If you leave it out, cosign refuses rather than checking less.

The bill of materials is signed the same way, with its own bundle:

Terminal window
cosign verify-blob \
--bundle sbom.spdx.json.sigstore.json \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
sbom.spdx.json

A verification you have only ever run against good input has told you nothing. Change a byte and watch it refuse:

Terminal window
cp checksums.txt tampered.txt
printf 'x' >> tampered.txt
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
tampered.txt

That must fail. The release workflow runs this same pair, the good file and the tampered copy, on every release, and refuses to publish if the tampered one is accepted.

The archives are reproducible. Building a tag again produces the same bytes, so you can compare a hash you computed against the one we published instead of trusting either of us.

Terminal window
git clone https://github.com/antifailure/antifailure
cd antifailure
git checkout v1.3.0
./tools/release/build.sh linux amd64 1.3.0 \
"$(git rev-parse HEAD)" "$(git show -s --format=%cI HEAD)" dist stage
sha256sum dist/antifailure_1.3.0_linux_amd64.tar.gz

That hash should be the line for your platform in checksums.txt. You need the same Go version the release used, which is the one in engine/go.mod.

Three things make this work, and all three are load bearing:

  • -trimpath, so the directory you built in does not reach the binary.
  • The build date comes from the commit, not from the clock. Every build of one commit therefore agrees.
  • The archive is written by tools/reltar rather than by tar, with a fixed modification time, no ownership, normalised permissions and sorted entries.

Without the third the binaries matched and the archives never did. tar takes each entry’s timestamp from the filesystem and gzip writes another into its own header, so two builds a minute apart produced two different archives of one identical binary. That is fixed, and just reproducible builds twice in two directories and compares, on every pull request.

What reproducibility here does and does not cover

Section titled “What reproducibility here does and does not cover”

Covered: the four release archives and the binaries inside them.

Not covered: sbom.spdx.json. An SPDX document records the moment it was created and a unique document namespace, so two runs differ by design. Verify it with its signature, not by rebuilding it.

sbom.spdx.json lists what is inside the binaries. It is read out of the built artifacts rather than generated from go.mod, because Go records the module graph it actually linked inside the binary. Reading the artifact answers what shipped; reading go.mod answers what was asked for. Those differ whenever a build constraint or a pruned dependency changes what the linker kept.

Every release runs tools/sbomcheck over it before publishing. That validates the document against the published SPDX 2.3 schema and then asks the question a schema cannot: does it record the SHA256 of every binary that actually ships. A bill of materials can be perfectly valid SPDX and describe nothing at all, which is exactly what this one did before the check existed.

One gap, stated rather than left to be found: the agent runner ships as source with playwright declared as a version range, resolved on your machine when you run af runner install. The bill of materials covers the Go dependencies compiled into af and cannot name a runner dependency version that is not chosen yet.

For maintainers. Everything below runs from a tag and nothing runs from a branch, because a release built from a branch is a release nobody can reproduce.

The same tag also deploys the hosted control plane, which this page does not cover because it is not something a person verifying a download needs to know. Cutting a release is the operational runbook: what green looks like at every stage of both workflows, and what to do when one of them goes red.

  1. Confirm the gates are green on the commit you are about to tag. just gate locally, and CI green on the merge.

  2. Write the release’s section in CHANGELOG.md, headed ## vX.Y.Z. The release publishes that section and nothing else, so a tag with no section, or with a heading and nothing under it, does not publish at all. just relnotes is that check and it runs on every pull request.

  3. Tag and push:

    Terminal window
    git tag -a v1.2.0 -m "v1.2.0"
    git push origin v1.2.0

    The tag is annotated and carries no signature. What is signed is checksums.txt and the bill of materials, by the publish job, which is what the verification steps above check. git verify-tag on a release tag of ours answers “no signature found”, and that is the honest answer rather than a broken one. Signing the tags too is what to set up if you want it to answer differently.

  4. Watch .github/workflows/release.yml. It builds four platforms, packages each with tools/release/build.sh, unpacks them so the bill of materials can read the binaries, signs checksums.txt and the bill of materials, verifies both signatures, proves a tampered file is rejected, and only then creates the release.

  5. Check the published artifacts the way this page tells a user to. If the instructions do not work, the release is not done.

  6. After the tag has published, and in its own commit, bump the Terraform image_tag defaults in infra/terraform/stacks/control-plane/variables.tf and infra/terraform/modules/control-plane/variables.tf to the new tag.

Step 6 is separate on purpose and it is the one step here that must not be done early. Those defaults are live: azurerm_container_app_job.maintenance reads the image with no ignore_changes, so an apply from main takes whatever they say. A default naming a tag that has not published yet does not produce a stale deployment, it produces a failed apply on the stack that runs the product. tools/tagsync is that ordering as a gate, so the mistake is a red check rather than a bad afternoon.

Step 6 is a person’s job on purpose, and it is not an oversight waiting to be automated. A release job that opened the bump as a pull request would need contents: write and pull-requests: write on a workflow whose stated rule is that only the publishing job gets write at all, and widening that surface is a change that deserves its own review rather than riding along with a release. The risk worth removing was the silent one, doing the bump too early, and tagsync removes it. Doing it late costs a stale default and nothing else.

Pushing the tag also publishes ghcr.io/antifailure/control-plane:<tag> and moves :latest onto it, which changes what anybody self hosting off latest gets on their next pull. Say so in the release notes.

The workflow fails rather than publishing when any of those checks fail. That ordering is the point: every previous version of this pipeline signed and published first and verified never.

Optional, and nobody has done it. A signed tag would say which maintainer cut the release. The artifact signature says something different and stronger: that this workflow, in this repository, at this tag produced the files. So a tag signature adds a second smaller claim, and its absence takes nothing away from the one you can already check.

Setting it up is the account owner’s work rather than the release pipeline’s, because it means holding a private key. Four steps, once:

  1. Have a key. An SSH key you already use is enough, or make a GPG key with gpg --full-generate-key.

  2. Tell git which key signs, and in which format:

    Terminal window
    git config --global gpg.format ssh
    git config --global user.signingkey ~/.ssh/id_ed25519.pub

    With GPG instead, leave gpg.format unset and give user.signingkey the key id.

  3. Add the public half to your GitHub account as a signing key, under Settings, SSH and GPG keys. Skip this and the signature is still good, and GitHub still shows the tag as unverified, because it has nothing to check against.

  4. Turn it on for every tag, so a forgotten flag cannot quietly produce an unsigned one:

    Terminal window
    git config --global tag.gpgsign true

Step 3 of the runbook then becomes git tag -s, and git verify-tag v1.2.0 starts answering. Until somebody does that, this page describes what the repository does rather than what it could do.

Do not delete the tag and re-push it. A tag that changes meaning breaks everybody who already fetched it, and it breaks the signature’s identity binding, which names the tag. Cut a new patch version instead and mark the bad release as such on GitHub.