Attesting build artefacts from a self-hosted runner Jump to heading
Hosted runners make provenance comparatively easy: every job gets a fresh machine, and the forge signs attestations with an identity the jobβs own steps cannot touch. Self-hosted runners remove both assumptions. A long-lived runner carries state from one job to the next, so a malicious pull request build can leave something behind for the release build. And if the signing key sits on the runner, any build step can use it to attest anything. Provenance from such a runner is a signed statement from a machine that might have been compromised by the previous job. This page fixes that: ephemeral runners, signing that build steps cannot reach, and provenance that records which runner image did the work. It belongs to build provenance and attestations.
When to use this approach Jump to heading
- Release builds must run on your own hardware β for special CPUs, network access, licensing or cost.
- You want provenance for those builds that is as trustworthy as provenance from hosted runners.
- Your runners are currently long-lived and shared between pull-request and release jobs.
- You understand what the build levels require; if not, start with understanding SLSA build levels for Git repositories.
Step 1 β Separate release runners from everything else Jump to heading
The first and cheapest fix is a dedicated runner group for release builds that never runs pull-request jobs. Untrusted code and release builds then never share a machine, even before you make runners ephemeral.
# Create a runner group restricted to the release workflow on the default branch
gh api -X POST "orgs/$ORG/actions/runner-groups" \
-f name=release-builders -f visibility=selected \
-F restricted_to_workflows=true \
-f 'selected_workflows[]='"$ORG/$REPO/.github/workflows/release.yml@refs/heads/main" # release.yml β the only workflow that can use these runners
jobs:
build:
runs-on: { group: release-builders, labels: [linux, x64] } Step 2 β Make release runners ephemeral Jump to heading
A dedicated group still lets one release job affect the next. Register runners with --ephemeral so each one takes exactly one job and then deregisters; a fresh machine or container from a known image takes its place.
# On the runner image's start-up: register, run one job, exit
./config.sh --url "https://github.com/$ORG" --token "$REG_TOKEN" \
--runnergroup release-builders --labels linux,x64 --ephemeral --unattended
./run.sh
# The host's orchestrator then destroys this VM or container and creates a new one The orchestrator is the important part: whatever starts runners must start each one from a clean image, never by reusing a stopped machine. Autoscaling controllers do this; the operational side is in autoscaling self-hosted runners.
# Verification: no runner in the group has completed more than one job
gh api "orgs/$ORG/actions/runner-groups/$GROUP_ID/runners" --jq '.runners[] | {name, status}' Step 3 β Keep signing out of the build steps Jump to heading
If the attestation is signed with a key the build steps can read, a compromised build step can sign a false attestation. Use the forgeβs attestation service, which signs with a short-lived certificate bound to the workflow identity and is reached through a token that is only minted for the attestation step.
jobs:
build:
runs-on: { group: release-builders }
permissions: { contents: read, id-token: write, attestations: write }
steps:
- uses: actions/checkout@v4
- run: make release # build steps: no signing material anywhere
- uses: actions/attest-build-provenance@v1
with: { subject-path: "dist/*.tar.gz" } Step 4 β Record what the runner was Jump to heading
Provenance from a hosted runner implicitly names a well-known image. Provenance from your runner should say which image it booted from, so a verifier can confirm the build ran on a machine you built and maintain.
# Bake the image identity into the runner at build time
echo "RUNNER_IMAGE=registry.example.com/runners/release@sha256:$(cat /etc/image-digest)" >> /etc/environment - run: |
jq -n --arg img "$RUNNER_IMAGE" --arg host "$(hostname)" \
'{runnerImage: $img, runnerHost: $host}' > runner-facts.json
- uses: actions/attest@v1
with:
subject-path: "dist/*.tar.gz"
predicate-type: "https://example.com/runner-facts/v1"
predicate-path: runner-facts.json A verifier can then check both attestations: the standard provenance for source and workflow, and the runner facts for the image digest, against a list of images you approved.
Step 5 β Verify the attestations before trusting the artefact Jump to heading
The deploy or publish step should verify that the artefact was attested by the release workflow, from the default branch, and that the runner image is on the approved list.
gh attestation verify dist/app-2.4.0.tar.gz --repo "$ORG/$REPO" \
--signer-workflow "$ORG/$REPO/.github/workflows/release.yml" --source-ref refs/heads/main
gh attestation verify dist/app-2.4.0.tar.gz --repo "$ORG/$REPO" \
--predicate-type https://example.com/runner-facts/v1 --format json |
jq -r '.[0].verificationResult.statement.predicate.runnerImage' |
grep -qxFf approved-runner-images.txt && echo "runner image approved" The general pattern for gating deploys is in verifying attestations before deploy.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Can self-hosted runners reach SLSA Build Level 3? Jump to heading
They can, if they are ephemeral, isolated from untrusted jobs, and signing is done by the platform rather than by build steps. The extra burden compared with hosted runners is that you must show the runner images are controlled.
What if our runners cannot reach the public signing service? Jump to heading
Run a private signing and transparency-log stack, or sign with a key held by a separate service the runner calls through an authenticated API. The requirement is that build steps cannot use the key directly, not that the service is public.
Do pull-request builds on self-hosted runners need attestations? Jump to heading
No β they produce nothing anyone consumes. They do need to stay off the release runners, which is the point of step 1.
Related Jump to heading
- Build Provenance & Attestations β the parent topic.
- Storing and Querying Attestations for Audits β keeping these attestations findable later.
- Reusing a Git Mirror on Self-Hosted Runners β speeding up ephemeral runners without shared mutable state.
- Limiting Workflow Permissions per Job β keeping
id-token: writeon the attest job only.