attestgen, .gitlab-ci.yml: sign a provenance document for each release tag

Problem. A release is a tag pushed by hand once the builders are green, and nothing but the tag says where its bytes came from. sum.golang.org guarantees everyone gets the same bytes for a version; it does not say which commit, which libsqlite3 and libsqlite_vec, or which pipeline they came from.

Change. A tag push now starts a pipeline with one job, attest, on a GitLab-hosted runner and an image pinned by digest (golang:1.27.1). Before anything is signed it:

  • checks the job's ID token (pushed tag, GitLab-hosted runner, this project, this commit), so nothing is signed that verification would reject;
  • runs the stamp test, clones the libsqlite3 and libsqlite_vec commits vendor.json names, and runs the new make vendor-check: every file vendor.json covers is deleted and vendored again without the cross-builds, and those files and vendor.json, apart from its go field, must come out as committed;
  • checks that the tag names the commit, and downloads the module twice, from gitlab.com and from proxy.golang.org checked against sum.golang.org, each into an empty cache with none of the runner's Go or git settings. Both must give the same hashes, and the direct one must come from the commit.

It then writes provenance.json, signs it with cosign keyless under the pipeline's GitLab identity, verifies the bundle including the certificate claims cosign has no GitLab flags for, stores document and bundle in the generic package registry (package attestation, version = tag), and links both from the GitLab release of the tag. VERIFYING.md says what a verified document proves, what it does not, and how to check one with attestgen or by hand; SECURITY.md has the one line pointing there.

Worth knowing before merging.

  • The job creates GitLab releases, which this project has none of yet. A release that already exists keeps its notes and only gains the two links; stored files are never overwritten, and a retried job reuses a stored pair once it verifies.
  • Asking proxy.golang.org for the version makes the proxy fetch it and sum.golang.org record it, as the first go get would.
  • The job cannot stop a release. If a check fails, it is red and nothing is signed.

Limits. The certificate proves that a push pipeline of this project, on a GitLab-hosted runner, ran the .gitlab-ci.yml of the tagged commit. That commit's own code runs with the identity, so a document is as trustworthy as the commit and the maintainer accounts that can create tags. vendor-check reproduces the copy into lib/ and vec/, not the transpilation behind it. The go field of vendor.json is taken as recorded; the document's builder.go says which Go reproduced the files. The keyless signing itself runs for the first time on the first tag after merge, since GitLab's OIDC token exists only inside a pipeline; everything around it was run locally.

Tested.

  • attestgen: 25 tests, and 28 positive controls, each check broken on purpose and caught.
  • Offline through real cosign v3.1.3, with a private CA issuing certificates shaped like Fulcio's for GitLab: cosign alone accepts certificates for a hand-started pipeline, a self-hosted runner, another commit and another project; attest -verify refuses each.
  • Live against v1.59.0: gitlab.com and proxy.golang.org with sum.golang.org give the same hashes, the known h1.
  • In the pinned image: on master the new steps pass (clone 18 s, vendor-check 21 s). vendor-check refuses a hand edit with a recomputed digest, an extra generated-looking file with a recomputed digest and count, a false undup pin, and a stamp naming the parent commit of libsqlite3 v1.14.5. A different Go patch release in the stamp passes, by design.
  • GitLab's CI lint in tag context gives only attest, and on master the three check jobs.
Edited by Ian Chechin

Merge request reports

Loading
Loading