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.
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.
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.1It 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.
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.
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 verifyA 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.
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.
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.pmThe 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.tarThe 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.
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.
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.