attest-go.yml
Generate and attach security attestations (SLSA provenance and/or SBOM) and/or a cosign keyless signature to an already-published Go release. Designed to run after release-go.yml, once its GoReleaser step has published the GitHub Release.
Inputs
| Input | Type | Description | Required | Default |
|---|---|---|---|---|
| TAG | string | Release tag to attest (e.g. the tag-name output of release-app.yml, or github.ref_name on a tag-push trigger) | Yes | - |
| WORKING_DIRECTORY | string | Directory holding the Go module (go.mod/go.sum) to catalog for SBOM. Only used when SBOM is true | No | "." |
| CHECKSUMS_FILE | string | Name of the checksum file release-go.yml's GoReleaser configuration produced, as a release asset | No | "checksums.txt" |
| PROVENANCE | boolean | Generate GitHub's standard SLSA build provenance attestation, covering every asset listed in CHECKSUMS_FILE | No | false |
| SBOM | boolean | Generate an SBOM from the Go module at WORKING_DIRECTORY, attest it, and upload it to the release | No | false |
| SIGN | boolean | Keyless-sign CHECKSUMS_FILE with cosign | No | false |
| RUNS_ON | string | Runner labels as JSON array (e.g., '["ubuntu-24.04"]' or '["self-hosted", "linux"]') | No | ["ubuntu-24.04"] |
Secrets
None. Unlike attest-docker.yml and attest-helm.yml, there is no registry involved - everything this workflow reads or writes is a GitHub Release asset, reached with the job's own GITHUB_TOKEN.
Permissions
| Scope | Access | Description |
|---|---|---|
| contents | write | gh release download (read) and gh release upload (write - the SBOM file and the cosign signature bundle) |
| id-token | write | Required to sign with cosign and to attest via OIDC |
| attestations | write | Required to create GitHub attestations |
Notes
- This workflow is designed to be called after
release-go.yml, using the tag it released. It reads the release back through the GitHub API (gh release download) rather than reusingrelease-go.yml's localdist/- the same reasonattest-docker.ymltakes aDIGESTinstead of a locally-built image: an attestation is only honest about what it names, and what a downstream user actually downloads is the published release asset, not a same-run copy of it. This also means noARTIFACT_NAMEhas to be kept in sync between the two workflows - only aneeds:ordering and the tag. - At least one of
PROVENANCE,SBOMorSIGNmust betruefor the job to perform a useful action. - Every capability is anchored to
CHECKSUMS_FILE, not to individual assets.actions/attestandactions/attest-build-provenanceboth accept a checksums file directly viasubject-checksums- a feature documented upstream by name for GoReleaser - so the resulting attestation lists every real release asset as a subject without this workflow re-deriving digests or guessing which files a release contains.SIGNsigns that same file with cosign: GoReleaser's checksum step already gives every other asset a verifiable link to it (sha256sum -c checksums.txt), so signing it once extends that existing verification story into "and this file's signer is provably this CI pipeline." - SBOM scans the Go module's source (
go.mod/go.sum), not the published binaries or archives. This is a deliberate difference fromattest-docker.yml's Trivy scan of a built image, for two reasons:- It would not work correctly. syft's directory cataloger does not unpack archives it merely finds while walking a directory - a folder holding only a
.tar.gzcatalogs zero packages, the same scan against the binary directly (or the archive as the explicit scan target) catalogs the real dependency count. Scanning downloaded release assets would need per-format extraction (tar,unzip,ar+tarfor.deb,cpiofor.rpm) with real failure modes of its own, for a result no more accurate than the option below. - It is not needed. A statically linked (
CGO_ENABLED=0) Go binary's dependency set is fully and exactly determined bygo.sum- unlike an OS package layered into a container image, nothing can be in the binary thatgo.sumdoes not name. Scanning the module source is not an approximation of scanning the binary; for this artifact class the two agree by construction, and the source is what syft's own Go-module cataloger is built to read directly.
- It would not work correctly. syft's directory cataloger does not unpack archives it merely finds while walking a directory - a folder holding only a
- A repository with more than one Go module gets a wider inventory than just this release.
SBOMscansWORKING_DIRECTORYas given. For a monorepo, or a plugin architecture where each plugin is built as an independent module, that directory may contain othergo.mod/go.sumfiles (and other ecosystems - syft also catalogs GitHub Actions usage under.github/workflows/, for instance) that have nothing to do with the binaries this specific release ships. NarrowWORKING_DIRECTORYto the module that produced this release, or accept the wider inventory. - SBOM attestation uses
actions/attestdirectly, notcosign- unlikeattest-docker.yml.actions/attestrefuses a predicate over 16 MiB, which is exactly whyattest-docker.ymlcannot use it for an image SBOM ("the transitive module graph of a few dozen statically linked Go... binaries runs to thousands of packages" - seeattest-docker.yml's notes). A single Go module's own dependency graph is that same graph, once - not duplicated per platform archive - and realistically stays well under the cap. This is a permanent choice for this workflow, not a size-conditional fallback: it buys one consistent verification story (gh attestation verify, the same command asPROVENANCE, visible in the repository's Attestations tab) instead ofcosign's separate command and invisibility there. A dependency graph large enough to exceed 16 MiB is a real limit of this workflow worth knowing about, not something it silently works around - disableSBOMif you hit it. - The SBOM is also uploaded to the release as a plain
sbom.spdx.jsonfile, alongside its attestation - the attestation is the durable, verifiable record; the plain file is for reading it directly without extracting it from the attestation bundle first. - Signing uses cosign's blob commands, not the OCI ones
attest-docker.yml/attest-helm.ymluse:cosign sign-blob --bundleagainst the downloaded checksums file, uploaded back to the release as<CHECKSUMS_FILE>.cosign.bundle. - Provenance is not gated on the SBOM or SIGN steps succeeding. It is the attestation that establishes where and from what the release was built, so a problem generating an SBOM or signing the checksums file must never be the reason a release ships without it.
- The checksums file's absence is diagnosed once, in its own step, with one message naming
CHECKSUMS_FILE- rather than as whichever of three different, more opaquecosign/actions/attestfailures happened to run first.
Verifying attestations
Provenance and SBOM are both GitHub attestations (also visible in the repository's Attestations tab), verified the same way, against the checksums file the subjects were derived from or against any individual downloaded asset:
gh attestation verify checksums.txt --owner <org>
gh attestation verify myapp_1.2.3_linux_amd64.tar.gz --owner <org>Signing is a cosign bundle, downloaded alongside the file it covers:
cosign verify-blob --bundle checksums.txt.cosign.bundle \
--certificate-identity-regexp '^https://github.com/<org>/<workflows-repo>/.github/workflows/attest-go.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txtIMPORTANT
The certificate identity is the reusable workflow that signed it - attest-go.yml in the repository hosting these workflows - not the repository being released. Keyless signing records the called workflow in the certificate, so anchoring the pattern to your own repository will fail to match. Do not drop the constraint to make it pass: without it, verification accepts a signature from anyone.
Examples
After a release, with provenance and SBOM
jobs:
release:
uses: this-is-tobi/github-workflows/.github/workflows/release-app.yml@v0
permissions:
contents: write
issues: write
pull-requests: write
secrets:
APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
binaries:
uses: this-is-tobi/github-workflows/.github/workflows/release-go.yml@v0
if: ${{ needs.release.outputs.release-created == 'true' }}
needs:
- release
permissions:
contents: write
packages: write
secrets:
APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
attest:
uses: this-is-tobi/github-workflows/.github/workflows/attest-go.yml@v0
needs:
- release
- binaries
permissions:
contents: write
id-token: write
attestations: write
with:
TAG: ${{ needs.release.outputs.tag-name }}
PROVENANCE: true
SBOM: trueProvenance and signing, on a tag-push trigger
Without release-please: TAG reads github.ref_name directly, and both jobs run off the same push: tags: trigger.
on:
push:
tags: ["v*"]
jobs:
release:
uses: this-is-tobi/github-workflows/.github/workflows/release-go.yml@v0
permissions:
contents: write
packages: write
secrets:
APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
attest:
uses: this-is-tobi/github-workflows/.github/workflows/attest-go.yml@v0
needs:
- release
permissions:
contents: write
id-token: write
attestations: write
with:
TAG: ${{ github.ref_name }}
PROVENANCE: true
SIGN: trueA module in a subdirectory, with a renamed checksum file
jobs:
attest:
uses: this-is-tobi/github-workflows/.github/workflows/attest-go.yml@v0
needs:
- release
- binaries
permissions:
contents: write
id-token: write
attestations: write
with:
TAG: ${{ needs.release.outputs.tag-name }}
WORKING_DIRECTORY: cmd/tool
CHECKSUMS_FILE: tool_checksums.txt
SBOM: true