Automating Immutable GitHub Releases with Build Provenance

In a previous post about the GitHub CLI, I mentioned an easy way to check the provenance of release artifacts with the GitHub CLI (specifically, using gh attestation verify ...).

What I didn't mention, however, was how to actually create those releases with build provenance.

Whelp, here goes...

Immutable Release vs Build Provenance

About a month ago, GitHub made immutable releases generally available. That is another step of supply chain integrity that everyone should enable. It ensures that published release assets can't be added/removed/changed, that the associated release tags can't be moved/changed, and they also ship with "release attestations"...

A GitHub release showing release attestations

Very important to note, that's not the same as build provenance, in spite of the term "attestation" thrown around liberally.

The GitHub CLI's docs about verifying release attestations (gh release verify ...) says:

Verify that a GitHub Release is accompanied by a valid cryptographically signed attestation.

That's not the same as attestation verification (gh attestation verify ...) despite roughly the same words being used.

'gh release verify' is nice shortcut

Immutable releases are great and I hope they become the default moving forward someday (i.e. make them opt-out, instead of opt-in).

The Immutable Release checkbox in Settings -> General

In my GitHub CLI post, I wrote a bit about verifying a file's SHA256 with what is on GitHub. Essentially, this is what the gh release verify and gh release verify-asset commands do with a small amount on top.

I created a throwaway repo to experiment with some of these commands (making it public was intentional, to test out the provenance system). I opened a repo, then I created a release using the Web UI and manually uploaded a text file from my computer, then I published the release as 0.0.0.

First, let's verify that the release has signed attestations (which are made automatically when using immutable releases).

gh release verify --repo sureshjoshi/scratch 0.0.0

  Resolved tag 0.0.0 to sha1:a0459681f61a50601e52478e2070bd21e969b1f2
  Loaded attestation from GitHub API
 Release 0.0.0 verified!

  Assets
  NAME         DIGEST                                                                 
  my-file.txt  sha256:084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0

Next, let's download and verify one of the release artifacts:

gh release download --repo sureshjoshi/scratch 0.0.0
gh release verify-asset --repo sureshjoshi/scratch 0.0.0 ./my-file.txt

  Calculated digest for my-file.txt: sha256:084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0
  Resolved tag 0.0.0 to sha1:a0459681f61a50601e52478e2070bd21e969b1f2
  Loaded attestation from GitHub API

 Verification succeeded! my-file.txt is present in release 0.0.0

Great, so we can confirm, based on the SHA256 of the artifact, that it was included in the 0.0.0 release on sureshjoshi/scratch.

What happens if I perform the same check on the original file that I uploaded from my computer into that release?

gh release verify-asset --repo sureshjoshi/scratch 0.0.0 ~/Documents/my-file.txt

  Calculated digest for my-file.txt: sha256:084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0
  Resolved tag 0.0.0 to sha1:a0459681f61a50601e52478e2070bd21e969b1f2
  Loaded attestation from GitHub API

 Verification succeeded! my-file.txt is present in release 0.0.0

It still passes verification, because the SHA256 sum of the file I uploaded is the same as the one I re-downloaded from GitHub. If I make a change to that file and check, release verification will fail as the SHA of the altered file won't match what the release annotations claim was published.

echo "bad" >> ~/Documents/my-file.txt 
gh release verify-asset --repo sureshjoshi/scratch 0.0.0 ~/Documents/my-file.txt

  attestation for 0.0.0 does not contain subject sha256:7d630f1a65483d26248ac6291be3df52e46be99decb8348d2ffddea501706026

sha256sum ~/Documents/my-file.txt
  7d630f1a65483d26248ac6291be3df52e46be99decb8348d2ffddea501706026  ~/Documents/my-file.txt

Release attestations are not build provenance

If you download and open the "Release Attestation" it's just a bunch of Base64-encoded JSON:

{
  "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
  "verificationMaterial": {
    "timestampVerificationData": {
      "rfc3161Timestamps": [
        {
          "signedTimestamp": "..."
        }
      ]
    },
    "certificate": {
      "rawBytes": "..."
    }
  },
  "dsseEnvelope": {
    "payload": "...",
    "payloadType": "application/vnd.in-toto+json",
    "signatures": [
      {
        "sig": "..."
      }
    ]
  }
}

Decoding the Base64 fields leads to information such as:

  • Signed at: 2025-11-29T14:28:28Z
  • Signed by: GitHub Inc
  • Signature validity dates
  • Payload repository: sureshjoshi/scratch
  • Artifact descriptors showing the artifact names and SHA256 for each, as well as the tagged version where they were located

Okay, so we have release attestations, right? That means we can run gh attestation verify as well?

gh release download --repo sureshjoshi/scratch 0.0.0
gh attestation verify --repo sureshjoshi/scratch ./my-file.txt 

  Loaded digest sha256:084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0 for file://my-file.txt
 Loading attestations from GitHub API failed

  Error: HTTP 404: Not Found (https://api.github.com/repos/sureshjoshi/scratch/attestations/sha256:084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0?per_page=30&predicate_type=https://slsa.dev/provenance/v1)

Not gonna lie, the first time I ran that, I held my breath worried that would somehow pass.

The "release annotations" are simply saying that we (GitHub) checked this release and confirm that my-file.txt with a SHA of 084...cc0 exists as an artifact in this release (with some extra security boilerplate).

It says NOTHING about how the artifact was made, where it came from, how it got there, etc... That's what build provenance is, and that's what gh attestation verify ... checks. Though, I do wish it was called gh provenance verify ... or gh attest provenance ... or something that makes it clear that this method checks the origin, while gh release verify-asset just checks SHA256's.

Generating releases with build provenance

Okay, so, we've enabled immutable builds which lock our release tags and assets after publishing. They also supposedly prevent me from deleting this repo and re-creating one with the same name and releases (untested, just unwisely trusting and not verifying GitHub on this one). The immutable build also generates release attestations which let me, functionally, confirm artifacts with the correct SHA256 digests are present.

Let's move to creating a release with some established build provenance.

If you already have an automated release process, the last piece is as simple as adding a step that uses actions/attest-build-provenance@v3 pointed at the important files and that's basically it. In this case, I'll give an example I have of automating a release with some extra sanity checks.

For more practical usage, I'm going to split the release process into two files. One is what I would run on pull requests, while the other is the dedicated release file which calls the CI first, and then performs the release. Alternatively, I could duplicate CI steps inside the release file, but I personally don't like that sort of duplication at all.

Continuous integration workflow

The CI workflow is pretty straight-forward. The interesting tidbit is the workflow_call which allows the file to be called by other jobs. In order to pass files between jobs, I upload them as artifacts and provide the name and path of the upload to the caller.

Note: I’ve recently been screwed over by artifact storage that climbed accidentally, and in spite of clearing out all my artifacts, I can no longer use CI until the end of the month. GitHub claims 6-12 hours, but it seems to be more like days before old artifact deletion is reflected. As a result, I've set organization-level defaults to 7 day artifact retention instead of the GitHub default of 90 days. This can also be set at the repo-level, and I've specifically included it in the upload-artifact action as well.

If you don't use any artifacts (logs or blobs or whatever), you can also conditionally allow uploads, only when this job is called from another job. It annoys me to no end that I need to use if: ${{ github.event_name == 'workflow_dispatch' }} here, as using workflow_call as the event name (in spite of that being the actual event) doesn't work. This is a known issue.

# .github/workflows/ci.yml

name: CI

on:
  pull_request:
    branches:
      - main
  workflow_call:
    outputs:
      artifact-name:
        description: The name of the uploaded artifacts
        value: ${{ jobs.build.outputs.artifact-name }}
      artifact-path:
        description: The path of the uploaded artifacts
        value: ${{ jobs.build.outputs.artifact-path }}

defaults:
  run:
    shell: bash

jobs:
  build:
    name: Checkout and build
    runs-on: ubuntu-latest
    outputs:
      artifact-name: ${{ steps.define-vars.outputs.artifact-name }}
      artifact-path: ${{ steps.define-vars.outputs.artifact-path }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Define output vars
        id: define-vars
        run: |
          echo "artifact-name=myfile" >> "$GITHUB_OUTPUT"
          echo "artifact-path=myfile-*" >> "$GITHUB_OUTPUT"

      - name: Do something contrived (could also define the artifact names inline here)
        run: |
          echo "1" > "myfile-$RANDOM.txt"
          echo "2" > "myfile-$RANDOM-1.txt"
          echo "3" > "myfile-$RANDOM-2.txt"

      - name: Upload artifacts
        id: upload-artifact
        if: ${{ github.event_name == 'workflow_dispatch' }}
        uses: actions/upload-artifact@v5
        with:
          name: ${{ steps.define-vars.outputs.artifact-name }}
          path: ${{ steps.define-vars.outputs.artifact-path }}
          retention-days: 7

Release workflow

The release workflow is much more interesting.

I don't like automatic releases to production based on GitHub tags or on every push to main, until the project is passed stabilization and well into maintenance with a robust test suite. I prefer a manual release kick-off on a pre-determined release cadence (this could even be kicked off via a cron job). However, as always, this depends on the project, team, automated testing, and the nature of the project and deployment environment (consumer-facing? dev-facing? backend code? mobile app?).

In the release script, I use a workflow_dispatch to kick off releases via the GitHub web UI, or in reality, using the gh workflow run ... CLI command.

# .github/workflows/release.yml

on:
  workflow_dispatch:
    inputs:
      version:
        description: The release version to create (e.g. 1.2.3).
        required: true

The first job is a pure sanity test on the passed-in version.

# .github/workflows/release.yml

determine-version:
  name: Validate and sanity check the requested version
  runs-on: ubuntu-latest
  outputs:
    release-version: ${{ steps.determine-version.outputs.release-version }}
  steps:
    - name: Sparse checkout
      uses: actions/checkout@v6
      with:
        sparse-checkout: |
          .
          scripts

    - name: Determine release version
      id: determine-version
      run: bash scripts/verify.sh ${{ github.event.inputs.version }}

This script expects a version in the form 1.2.3 and in my case, I added a sanity to ensure I’ve mentioned this version in the changelog. In a private example, I compare against the MARKETING_VERSION for an iOS SDK.

If it doesn’t work, I use GitHub’s error/log format to send nice error messages back to the Actions interface. Otherwise, I pipe key=value into the $GITHUB_OUTPUT to pass it along to later steps/jobs.

#!/usr/bin/env bash

set -euo pipefail

version=${1:-}

if [[ -z "$version" ]]; then
    echo "::error::An input 'version' must be specified"
    exit 1
fi

if [[ ! "${version}" =~ ^[0-9]+.[0-9]+.[0-9]+$ ]]; then
    echo "::error::The Release version '${version}' must match '\d+.\d+.\d+'."
    exit 1
fi

if grep -Fq -- "${version}" "CHANGELOG.md" ; then
    echo "::notice::Release version is: ${version}"
    echo "release-version=${version}" >> $GITHUB_OUTPUT
else
    echo "::error::The Release version '${version}' must exist in the CHANGELOG."
    exit 1
fi

Next, I call the CI job shown earlier:

# .github/workflows/release.yml

run-ci:
  needs: determine-version
  uses: ./.github/workflows/ci.yml

Finally, I create the (immutable) GitHub release with build provenance:

# .github/workflows/release.yml

github-release:
  name: Create a Github Release with build provenance attestations
  runs-on: ubuntu-latest
  needs:
    - determine-version
    - run-ci
  permissions:
    id-token: write
    attestations: write
    contents: write
  steps:
    - name: Download artifacts
      uses: actions/download-artifact@v5
      with:
        name: ${{ needs.run-ci.outputs.artifact-name }}

    - name: Generate
      env:
        GH_TOKEN: ${{ github.token }}
      run: |
        gh release create \
          ${{ needs.determine-version.outputs.release-version }} \
          ${{ needs.run-ci.outputs.artifact-path }} \
          --fail-on-no-commits \
          --generate-notes \
          --repo sureshjoshi/scratch
    
    - name: Generate ${{ needs.determine-version.outputs.release-version }} build provenance attestations
      uses: actions/attest-build-provenance@v3
      with:
        subject-path: ${{ needs.run-ci.outputs.artifact-path }}

Running the release workflow

As I use the GitHub CLI as much as possible, in order to kick off this process, I used:

gh workflow run release --raw-field "version=0.0.1"

 Created workflow_dispatch event for release.yml at main
  To see runs for this workflow, try: gh run list --workflow="release.yml"

Which creates this release seen here and these attestations. Now, let's verify everything works as expected with the immutable release attestations:

gh release verify --repo sureshjoshi/scratch 0.0.1
  
  Resolved tag 0.0.1 to sha1:cc2595b05ba2837d5f7d7aad8b3412f7a3769a4a
  Loaded attestation from GitHub API
 Release 0.0.1 verified!

  Assets
  NAME                DIGEST                                                                 
  myfile-14392.txt    sha256:4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865
  myfile-15390-1.txt  sha256:53c234e5e8472b6ac51c1ae1cab3fe06fad053beb8ebfd8977b010655bfdd3c3
  myfile-22161-2.txt  sha256:1121cfccd5913f0a63fec40a6ffd44ea64f9dc135c66634ba001d10bcf4302a2

And most importantly, the build provenance:

gh release download --repo sureshjoshi/scratch 0.0.1  
gh attestation verify --repo sureshjoshi/scratch ./myfile-14392.txt

  Loaded digest sha256:4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 for file://myfile-14392.txt
  Loaded 1 attestation from GitHub API

  The following policy criteria will be enforced:
  - Predicate type must match:................ https://slsa.dev/provenance/v1
  - Source Repository Owner URI must match:... https://github.com/sureshjoshi
  - Source Repository URI must match:......... https://github.com/sureshjoshi/scratch
  - Subject Alternative Name must match regex: (?i)^https://github.com/sureshjoshi/scratch/
  - OIDC Issuer must match:................... https://token.actions.githubusercontent.com

 Verification succeeded!

  The following 1 attestation matched the policy criteria

  - Attestation #1
    - Build repo:..... sureshjoshi/scratch
    - Build workflow:. .github/workflows/release.yml@refs/heads/main
    - Signer repo:.... sureshjoshi/scratch
    - Signer workflow: .github/workflows/release.yml@refs/heads/main

Easy peasy.

Final notes

There were a few items that I wrote about which might not make sense offhand:

  • I use sparse checkouts when I don't need the entire working repo to do something (e.g. check a Changelog or run a script)
  • When performing the release, you need to specify the --repo to use gh (alternatively, you can do a sparse checkout, just to get the .git folder and use the local --repo instead)
  • I mentioned the workflow_dispatch check in lieu of workflow_call - but I'm still annoyed and am mentioning it again

On a slightly different topic, I updated to pnpm 10.24.0 yesterday and saw a new configuration for trustPolicy which errors out instead of upgrading to a new version of a dependency which had build provenance, but then removed it. I was ...surprised... to see that several dependencies of a tool I used had recently downgraded their build provenance - which seems sketchy as hell.

But huge shout-out to pnpm for landing release after release of supply chain security improvements to try to deal with the crapfest that npm and the Javascript ecosystem is.