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"...

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).

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
--repoto usegh(alternatively, you can do a sparse checkout, just to get the.gitfolder and use the local--repoinstead) - I mentioned the
workflow_dispatchcheck in lieu ofworkflow_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.
