Skip to content

Latest commit

 

History

History
161 lines (128 loc) · 8.5 KB

provenance.md

File metadata and controls

161 lines (128 loc) · 8.5 KB

Build provenance

What is attested about the package hex.pm serves, what the attestation binds to, how to verify it, and how to reproduce the bytes without trusting anyone. Where a sentence here and a measurement disagree, the measurement is the record; the measurements are named.

What is attested, and what it binds to

On every release tag, CI builds the Hex tarball and attests its SHA-256 with GitHub's build-provenance attestation (SLSA build provenance, Sigstore-signed, held in the repository's attestation store), naming the repository, the commit and the workflow that built it (.github/workflows/provenance.yml). The outer tarball's SHA-256 is the "Package checksum" hex.pm records, so the attested subject is the identifier a consumer already sees on the package page. The owner tags and publishes; the workflow attests and never publishes.

The bytes are canonical by construction, not by luck

mix hex.build packages the working tree as it lies, and two things about a working tree are the machine's, not the commit's: each entry carries the file's on-disk mode (a checkout under umask 002 gives 664, under 022 gives 644), and a directory named in files: is walked in readdir order (ext4's per-filesystem hash order; tmpfs's another). Measured on 2026-09-17: one commit gave three checksums (this machine's tree, a umask-022 checkout, a tmpfs checkout) with the same 39 files inside. A tarball built from a working tree is that machine's; an attestation over it would describe bytes no other machine reproduces.

So the release tarball is built by one script on every seat: CI, the publisher, a stranger reproducing it:

RELEASE_HEX=2.5.1 tools/release_tarball.sh v0.10.1 beam_mcp-0.10.1.tar   # 0.10.1 was built with Hex 2.5.1

It git archives the ref with tar.umask=022 (every file 644 whatever the machine's umask, and only tracked files; a draft under docs/ cannot ship), extracts with permissions preserved, resolves the lock's dependencies, and runs mix hex.build there; mix.exs names its files: as globs, so the entry order is Path.wildcard's sort and not a filesystem's. Measured: the same commit built this way on ext4 and on tmpfs gave one tarball, byte for byte, every entry 644. A test builds it and pins the structure (the modes, the order, the checksum) on every run of the suite.

A release verifies only if it was published with the same script (--publish runs mix hex.publish from the canonical tree). The tag's run downloads the bytes hex.pm serves and verifies the attestation against them; a release published from a working tree fails that step, loudly, and carries an attestation that describes nothing on hex.pm. The order is publish, then push the tag: on a tag the run treats a tarball hex.pm does not yet serve as a failure, not as a pass.

Verify a published tarball

v=0.10.1
curl -fsSLO "https://repo.hex.pm/tarballs/beam_mcp-${v}.tar"
gh attestation verify "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp \
  --signer-workflow ScriptKittyOS/beam_mcp/.github/workflows/provenance.yml

--signer-workflow holds the attestation to this repository's provenance workflow, not merely to any workflow in the repository.

gh attestation needs GitHub CLI 2.49 or newer (Ubuntu's packaged 2.45 does not have it, measured here), and it is the verifier GitHub documents; the attestation is a Sigstore bundle (gh attestation download fetches it; the repository's Actions tab lists each one under Attestations), so another Sigstore verifier can read it, but no such path is measured here and none is claimed. What a verifier proves: the tarball's digest is the one a run of provenance.yml at a named commit of this repository produced, signed through Sigstore at the time. What it does not prove: anything about that commit's contents; that is the tree's own record (the gate, the review record), reachable from the commit the attestation names.

Verify a release tag's signature

Every release tag, v0.1.0 on, is an annotated tag signed with the maintainer's OpenPGP key. The key is published on the maintainer's GitHub account (the login CODEOWNERS names), and its fingerprint is:

24FE 4F05 E3E8 EC26 1462  A0C7 82A6 7035 D628 7F15
curl -fsSL https://github.com/HackTuah.gpg | gpg --import
gpg --fingerprint 24FE4F05E3E8EC261462A0C782A67035D6287F15   # compare with the line above
git fetch --tags origin && git tag -v "v${v}"                  # "Good signature" or it did not verify

A tag signature says the maintainer named that commit as the release; the attestation above says which bytes CI built from it; the two together connect the source you can read to the tarball hex.pm serves. The private key is held on the maintainer's own machine, not on GitHub or hex.pm, which only distribute. If the key is ever replaced, this page and the CHANGELOG say so in the same commit, with the new fingerprint.

The SBOM

From 0.10.0 each release also carries a software bill of materials: CycloneDX 1.6, JSON, the runtime dependency set (the Hex packages at their locked versions, and the OTP and Elixir applications), generated from the tag's tree by tools/sbom.sh with the EEF's mix_sbom (pinned by version and by the release asset's SHA-256; nothing is added to mix.exs or mix.lock) and attested to the same tarball digest as the provenance. plug and bandit appear although they are optional dependencies of this package: the tool has no notion of an optional dependency. Releases up to 0.10.1 were generated with mix_sbom 0.11.0, which could not read the tools: :optional entry in mix.exs, so the script rewrote that one line to :tools in its scratch copy (the component list is the same). From 0.12.0 the tool reads the form and the script reads the tree exactly as tagged.

gh attestation verify "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp \
  --predicate-type https://cyclonedx.org/bom
gh attestation download "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp \
  --predicate-type https://cyclonedx.org/bom        # writes sha256:<digest>.jsonl
jq -r '.dsseEnvelope.payload' sha256:*.jsonl | base64 -d | jq '.predicate' > "beam_mcp-${v}.cdx.json"

The release workflow runs the download and the extraction above on every tag and compares the result with the document it generated, so the commands are the ones measured. The SBOM is not byte-reproducible (CycloneDX gives each document a fresh serial number and timestamp); what binds it to the package is the attestation over the tarball's digest.

Reproduce the bytes

Trust nothing above; build it:

git clone https://github.com/ScriptKittyOS/beam_mcp && cd beam_mcp
tools/release_tarball.sh "v${v}" "rebuilt-${v}.tar"     # prints the sha256 = the package checksum on hex.pm

The script needs Elixir, Erlang, the Hex that tools/hex-version names, bash, git and sha256sum or shasum, and it refuses any other Hex with the command that installs the right one. CI builds with the pair .tool-versions names, copied into the workflow (Elixir 1.18 on OTP 28), and installs the same Hex from the same file. The tarball carries no compiled code, and Hex is what packages it, which is why Hex is pinned: 2.4.0 and 2.5.1 gave byte-identical tarballs of one commit (measured), but 2.5.2 (hex_core 0.20) sorts the tarball's entries and its metadata files list, so the same commit has a different checksum under 2.5.1 and under 2.5.2 or later (measured 2026-10-19). Releases up to and including 0.10.1 were built with Hex 2.5.1 and reproduce byte for byte only under it:

mix local.hex 2.5.1 --force    # from inside the clone: a version manager keeps one Hex per Elixir
RELEASE_HEX=2.5.1 tools/release_tarball.sh v0.10.1 rebuilt-0.10.1.tar

The canonical bytes assume ASCII file names: a non-ASCII name is encoded by the machine's locale, and a test holds every packaged name to ASCII.

Releases before this page

0.5.0 and earlier carry no attestation and were built from working trees: their bytes are the publishing machine's (the 0.5.0 tarball's entries carry 664), reproducible on that machine (measured for 0.5.0) and not by the script above, which builds 644 entries in glob order. Their provenance is the tag and the checksum, nothing more.

Hex's own transparency log

When Hex ships a native transparency log for releases, this page moves the verification to it and keeps the GitHub attestation as the second witness; nothing about what is attested changes, since the subject is already the checksum Hex records.