Skip to content

ci(release): signed GitHub Releases with Sigstore signatures and SLSA provenance - #55

Merged
carbon-evolution merged 1 commit into
mainfrom
ci/signed-releases
Oct 18, 2026
Merged

carbon-evolution merged 1 commit into
mainfrom
ci/signed-releases

Conversation

@carbon-evolution

Copy link
Copy Markdown
Owner

Summary

Every v* tag now produces a signed GitHub Release alongside the container image the workflow already signs. The goal is for OpenSSF Scorecard's Signed-Releases check to score (today it reports "no releases found") and for users to be able to verify what they download.

Release contents (example v0.2.0)

Asset What it is
osiris-v0.2.0-source.tar.gz git archive --prefix=osiris-v0.2.0/ HEAD | gzip -n -9. Reproducible from the tag.
osiris-v0.2.0-standalone.tar.gz Next.js standalone build laid out per Next's deployment docs (.next/standalone + .next/static + public); runs with node server.js. Tarred with sorted names, a fixed mtime (commit time) and numeric root owner.
osiris-v0.2.0-sbom.cdx.json CycloneDX 1.5 SBOM of the production npm tree, from npm sbom --omit=dev (built into npm, so there is no extra tool to pin).
SHA256SUMS Checksums of the three files above.
*.sigstore.json (×4) Keyless cosign sign-blob --bundle bundles for each file and for SHA256SUMS.
osiris-v0.2.0.intoto.jsonl SLSA v1 build provenance for the three files: the Sigstore bundle produced by actions/attest-build-provenance.

The image job (build, push, sign, verify, plus the BuildKit SBOM and provenance: mode=max) is unchanged. The only edit there is a # shellcheck disable=SC2086 directive, because its word splitting is intentional.

Signing / provenance design

  • Jobs:
    • container: unchanged.
    • artifacts: runs npm ci and the build, packages the files, writes the SBOM and SHA256SUMS, then signs each file with cosign. It runs cosign verify-blob against https://github.com/carbon-evolution/osiris/.github/workflows/release.yml@refs/tags/<tag>, attests build provenance, and runs gh attestation verify on every file.
    • release: needs both jobs above. It runs sha256sum -c again, then gh release create --verify-tag --generate-notes.
    • The release is published only after the image is signed and verified. If the workflow is re-run for a tag whose release already exists, it re-uploads the assets with --clobber. workflow_dispatch on a branch runs only the container job.
  • Signature format: Scorecard's releasesAreSigned probe accepts .asc .minisig .sig .sign .sigstore .sigstore.json (source). We use .sigstore.json, which is what cosign v3 writes by default.
  • Provenance, and why not slsa-github-generator: releasesHaveProvenance only looks for a *.intoto.jsonl asset (source).
    • The brief suggested the SLSA generic generator and said Scorecard accepts tag-pinning it. The Scorecard source says otherwise. Its own test (pinned_dependencies_test.go, case "job uses with no steps") reports slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.2.0 as an unpinned dependency. Pinned-Dependencies (currently 10) would drop to about 9. The generator would also need contents: write on its calling job.
    • So I used actions/attest-build-provenance@4d10147… (v4.2.2). It is SHA-pinned and GitHub-owned, and needs only attestations: write + id-token: write. It produces a Sigstore-signed in-toto statement with a SLSA v1 predicate, stored in the repo's attestations.
    • We also ship the same bundle as osiris-<tag>.intoto.jsonl. The Bazel Central Registry follows the same convention for attest-build-provenance bundles.
    • It is verified with gh attestation verify, either offline with --bundle or online against GitHub's attestation store. I checked the exact flag set (--bundle --signer-workflow --source-ref --deny-self-hosted-runners) against a real attestation from cli/cli: it passes, and --source-ref is enforced.
    • Trade-off: the generator runs provenance creation in an isolated job (SLSA Build L3). attest-build-provenance creates it in the build job itself (Build L2), but keeps every dependency hash-pinned. If you prefer L3 and accept the Pinned-Dependencies point, the swap is small.
  • Least privilege:
    • The workflow default is contents: read.
    • artifacts has contents: read, id-token: write and attestations: write.
    • release is the only job with contents: write.
    • All actions are SHA-pinned with # vX.Y.Z comments. upload-artifact uses the SHA already in the repo; download-artifact is pinned at 9000827… (v8.0.2).
    • Expression values reach shell only via env:.
  • Notes: there is no CHANGELOG, so the release uses GitHub's generated notes with a short "Verify this release" preamble.

Verification commands (also in DOCKER.md → "4. Verifying a release"; README gets a short "Signed releases" section)

sha256sum -c SHA256SUMS      # macOS: shasum -a 256 -c SHA256SUMS
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
  --certificate-identity 'https://github.com/carbon-evolution/osiris/.github/workflows/release.yml@refs/tags/v0.2.0' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
gh attestation verify osiris-v0.2.0-standalone.tar.gz --repo carbon-evolution/osiris \
  --bundle osiris-v0.2.0.intoto.jsonl \
  --signer-workflow carbon-evolution/osiris/.github/workflows/release.yml \
  --source-ref refs/tags/v0.2.0 --deny-self-hosted-runners
cosign verify ghcr.io/carbon-evolution/osiris:0.2.0 \
  --certificate-identity 'https://github.com/carbon-evolution/osiris/.github/workflows/release.yml@refs/tags/v0.2.0' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Local verification done

  • actionlint: rhysd/actionlint 1.7.12 with shellcheck reports nothing on release.yml. The pre-existing SC2034 in zap.yml is untouched.
  • Packaging: I ran npm ci && npm run build, then the packaging step's run: block, extracted verbatim from the YAML, in a node:22 container (GNU tar and coreutils).
    • sha256sum -c SHA256SUMS passes.
    • Two runs gave byte-identical source and standalone tarballs. The SBOM differs between runs because of its timestamp and serial number.
    • The decompressed source tarball matches a local git archive on macOS.
  • Standalone server: I extracted the standalone tarball into a temp dir and ran PORT=3017 node server.js. GET / returned 200 (52 KB). /_next/static/chunks/*.js, *.css and the public/ files all returned 200. The server was stopped afterwards.
  • Sign/verify/publish dry run: I ran the shell logic with dummy files and stub cosign/gh, so nothing was signed with a real identity.
    • Every file gets a bundle, and each bundle verifies against the expected identity.
    • A tampered file fails verification.
    • Provenance is copied to osiris-v0.2.0.intoto.jsonl, and gh attestation verify is called with the right flags for each file.
    • gh release create receives all 9 assets plus the notes.

Version and first tag

v0.1.0 already exists: a manually created release with no assets. Scorecard skips releases without assets, which is why it reports "no releases found". There are 38 commits since then. This PR bumps package.json and package-lock.json to 0.2.0 (npm version 0.2.0 --no-git-tag-version), so the next tag matches the package version.

After merging, from an up-to-date main:

git checkout main && git pull
git tag -s v0.2.0 -m "OSIRIS v0.2.0 — first signed release (Sigstore + SLSA provenance)"
git push origin v0.2.0

Scorecard expectation

Signed-Releases scores 10 per release that has provenance and 8 for signatures only, averaged over the last 5 releases that have assets. v0.1.0 has no assets, so v0.2.0 should move the check from "no releases found" to 10/10. Pinned-Dependencies stays at 10 because everything is hash-pinned. Token-Permissions should stay at 10, since all write scopes are job-level.

What to check after tagging

  1. The release run is green: container + artifacts → release.
  2. The release lists 9 assets, and the commands above pass on downloaded copies.
  3. The attestation appears under the repo's Attestations.
  4. ghcr.io/carbon-evolution/osiris:0.2.0 passes cosign verify.
  5. The next Scorecard run shows Signed-Releases at 10. It runs on push to main and weekly; the release itself doesn't trigger it.

🤖 Generated with Claude Code

On a v* tag, release.yml now also:

- builds a reproducible source archive (git archive | gzip -n) and the
  Next.js standalone bundle (.next/standalone + .next/static + public,
  runs with `node server.js`), plus a CycloneDX SBOM of the production
  npm tree (npm sbom) and SHA256SUMS;
- keyless-signs every file and SHA256SUMS with cosign sign-blob
  (<file>.sigstore.json bundles, an extension Scorecard counts) and
  verifies them against the workflow identity;
- attests SLSA v1 build provenance for the files with the SHA-pinned
  actions/attest-build-provenance, ships its bundle as
  osiris-<tag>.intoto.jsonl (what Scorecard looks for) and verifies it
  with `gh attestation verify`. slsa-github-generator was not used:
  its reusable workflow can only be tag-pinned, which Scorecard's
  Pinned-Dependencies check counts as unpinned;
- creates the GitHub Release with generated notes and all assets, only
  after the image is built, signed and verified. contents: write is
  scoped to that release job only.

The container job is unchanged apart from a shellcheck directive for its
intentional word splitting.

Bump package.json/package-lock.json to 0.2.0 so the first signed tag
(v0.2.0) matches the package version; v0.1.0 already exists as an
asset-less release. DOCKER.md gains "Verifying a release" and README a
short "Signed releases" section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@carbon-evolution
carbon-evolution merged commit fabbc82 into main Oct 18, 2026
8 checks passed
@carbon-evolution
carbon-evolution deleted the ci/signed-releases branch October 18, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant