How to Generate an SBOM With Syft in GitHub Actions (2026)
This guide is for engineers who build container images in GitHub Actions and now need an SBOM for every release, because a customer, an auditor or the EU Cyber Resilience Act asked. By the end you will have one workflow that builds and pushes an image, generates SPDX 2.3 and CycloneDX 1.6 SBOMs from that exact image digest with Syft, signs them as attestations stored next to the image, scans the SBOM with Grype into GitHub code scanning, and uploads it to Dependency-Track for continuous monitoring. Versions were checked against GitHub releases on 10 November 2026: Syft v1.57.1, Grype v0.121.3, cosign v3.1.4, actions/attest v4.2.2, anchore/sbom-action v0.24.3 and Dependency-Track 5.2.1. You need a repository that builds a Dockerfile, permission to push to GitHub Container Registry, and optionally a Dependency-Track instance with an API key. If you want the background on formats and rules first, read SBOM explained: SPDX vs CycloneDX in 2026.
Step 1: Generate an SBOM locally with Syft first
Run it once on your laptop before you automate it. You will learn what Syft sees in your image and catch format surprises early. Install into a local directory, pinned:
# Pinned install into ./bin; the script takes the tag as its last argument
curl -sSfL https://get.anchore.io/syft | sh -s -- -b ./bin v1.57.1
./bin/syft version | head -3
# Scan a public image and write both formats with explicit spec versions
./bin/syft scan alpine:3.22 \
-o spdx-json@2.3=sbom.spdx.json \
-o cyclonedx-json@1.6=sbom.cdx.json \
-o table
Check what you got. These fields are the ones downstream tools use to recognise the format:
jq -r '.spdxVersion, .SPDXID' sbom.spdx.json
# SPDX-2.3
# SPDXRef-DOCUMENT
jq -r '.bomFormat, .specVersion, (.components | length)' sbom.cdx.json
# CycloneDX
# 1.6
# <number of components>
Why pin the output versions? Syft's format documentation lists spdx-json 2.2, 2.3 and 3.0 and cyclonedx-json up to 1.7, and says it uses the latest supported version when you do not say (in the v1.57.1 source the unpinned SPDX default is actually still 2.3, while CycloneDX defaults to 1.7). Relying on either default is fine for Syft and bad for consumers: GitHub's attestation action identifies SPDX by the spdxVersion and SPDXID fields, which SPDX 3 documents do not have. CycloneDX 1.6 is my conservative pick; Dependency-Track's docs say it accepts all CycloneDX versions, so move to 1.7 once your other consumers do too.
Step 2: Build and push the image, keep the digest
Everything after this step uses the image digest, not a tag. Tags move; the SBOM must describe one set of bytes. Create .github/workflows/sbom.yml. Actions are pinned to full commit SHAs, with the release tag in a comment, which matters for a supply-chain workflow more than most (see GitHub Actions security in 2026).
name: build-sbom
on:
push:
branches: [main]
tags: ["v*"]
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # push to GHCR
id-token: write # Sigstore OIDC for attestations
attestations: write # store attestations
artifact-metadata: write # storage records for pushed images
security-events: write # upload Grype SARIF
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Lowercase image name (GHCR rejects uppercase)
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
The digest output of build-push-action (for example sha256:4f1c...) is what the next steps consume as ${{ steps.build.outputs.digest }}.
Step 3: Generate SPDX and CycloneDX SBOMs with Syft in the workflow
You can use anchore/sbom-action directly, but its format input takes a bare format name (spdx-json, cyclonedx-json and so on) and the action v0.24.3 defaults to Syft v1.54.0 unless you set syft-version. I prefer its download-syft sub-action, which installs a pinned Syft and hands back the binary path, so I can pass explicit spec versions on the command line. Append these steps:
- name: Install Syft
id: syft
uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
syft-version: v1.57.1
- name: Generate SBOMs from the pushed image digest
env:
SYFT: ${{ steps.syft.outputs.cmd }}
REF: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
run: |
# Syft reuses the docker/login-action credentials in ~/.docker/config.json
"$SYFT" scan --from registry "$REF" \
-o spdx-json@2.3=sbom.spdx.json \
-o cyclonedx-json@1.6=sbom.cdx.json
jq -r '.spdxVersion' sbom.spdx.json
jq -r '.specVersion, (.components | length)' sbom.cdx.json
- name: Keep the SBOMs as workflow artifacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: sbom-${{ github.sha }}
path: sbom.*.json
retention-days: 90
--from registry tells Syft to pull straight from the registry and bypass any local container runtime, which avoids scanning a stale local copy; Syft's scan-target docs say it reads credentials from ~/.docker/config.json first. Ninety days of artifact retention is a convenience, not an archive: the attestation in the next step is the copy that lives with the image.
Step 4: Attest the SBOM with GitHub artifact attestations
actions/attest-sbom still works, but its README says it is being deprecated in favour of actions/attest, which switches to SBOM mode when you pass sbom-path. It signs with a short-lived Sigstore certificate (public-good Sigstore for public repositories, GitHub's private instance for private ones) and, with push-to-registry, stores the attestation in GHCR next to the image.
- name: Attest SPDX SBOM
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }} # no tag here, the digest identifies it
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.spdx.json
push-to-registry: true
- name: Attest CycloneDX SBOM
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.cdx.json
push-to-registry: true
Limits worth knowing from the README: the SBOM file must be 16 MB or smaller, and artifact attestations in private or internal repositories need GitHub Enterprise Cloud. On other plans, use cosign instead.
Alternative: attest with cosign keyless
The same result with cosign v3.1.4, whose --type accepts spdxjson and cyclonedx:
- uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3 (installs cosign v3.1.4)
- name: Attest CycloneDX SBOM with cosign
env:
REF: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
run: cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$REF"
The cosign signing guide covers keyless identities in more detail.
Step 5: Upload the SBOM to Dependency-Track
A scan in CI tells you about today. Dependency-Track re-evaluates stored SBOMs as new advisories arrive, which is the point of keeping them. Its file-format reference says CycloneDX is the only format accepted for upload, so send sbom.cdx.json. Create a service account or team API key with BOM_UPLOAD, plus PROJECT_CREATION_UPLOAD if you want first uploads to create the project, as the uploading guide describes. Store the key as the DT_API_KEY secret.
- name: Upload CycloneDX SBOM to Dependency-Track
uses: DependencyTrack/gh-upload-sbom@331f980a6eba119842052f447c54b09e4888b4c6 # v4.3.0
with:
server-hostname: dtrack.example.com
api-key: ${{ secrets.DT_API_KEY }}
project-name: ${{ github.repository }}
project-version: ${{ github.ref_name }}
bom-filename: sbom.cdx.json
auto-create: true
On Dependency-Track 5.2.0 or later you can drop the stored key: the action accepts workload-identity-provider, service-account and oidc-audience and exchanges the job's GitHub OIDC token for a short-lived session. Inputs were renamed to kebab-case in v4.2.0; the old names such as apikey still work with a deprecation warning.
If you would rather not use the action, the equivalent REST call is a multipart POST:
curl -sSf -X POST "https://dtrack.example.com/api/v1/bom" \
-H "X-Api-Key: ${DT_API_KEY}" \
-F "projectName=acme/api" \
-F "projectVersion=v1.4.0" \
-F "autoCreate=true" \
-F "bom=@sbom.cdx.json"
# {"token":"<uuid>","projectUuid":"<uuid>"} ; poll /api/v1/event/token/<token> for COMPLETED
Step 6: Scan the SBOM with Grype and fail on high severity
Scanning the SBOM rather than the image again means the vulnerability report and the inventory can never disagree about what was in the build. anchore/scan-action v7.4.2 defaults to Grype v0.118.0, so pin the current one:
- name: Scan SBOM with Grype
id: scan
uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2
with:
sbom: sbom.cdx.json
grype-version: v0.121.3
fail-build: true
severity-cutoff: high
only-fixed: true # do not fail on CVEs with no fix yet
output-format: sarif
- name: Upload Grype results to code scanning
if: ${{ !cancelled() }}
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
Put this step last. If it fails the build, the image, signed SBOMs and Dependency-Track record already exist, which is what you want for triage. To suppress a reviewed false positive, keep an OpenVEX file in the repository and pass it with the action's vex input (or grype --vex on the CLI). Locally, the same gate is:
grype sbom:./sbom.cdx.json --fail-on high --only-fixed -o table
echo "exit code: $?" # 2 when a match meets --fail-on
For more on tuning scanners, see the Trivy and Grype scanning guide.
Step 7: Verify the SBOM attestation
Verification is what turns an attached file into evidence. gh attestation verify enforces the SLSA provenance predicate by default, so tell it which SBOM predicate you want. The predicate types come from actions/attest's source: https://spdx.dev/Document/v2.3 for SPDX 2.3 and https://cyclonedx.org/bom for CycloneDX.
IMAGE=ghcr.io/acme/api
DIGEST=sha256:<digest-from-step-2>
# GitHub artifact attestations (log in to ghcr.io first)
gh attestation verify "oci://${IMAGE}@${DIGEST}" \
--owner acme \
--predicate-type https://spdx.dev/Document/v2.3
# Pull the CycloneDX SBOM back out of the verified attestation
gh attestation verify "oci://${IMAGE}@${DIGEST}" \
--owner acme \
--predicate-type https://cyclonedx.org/bom \
--format json | jq '.[0].verificationResult.statement.predicate.specVersion'
# cosign keyless attestations: pin the workflow identity and issuer
cosign verify-attestation --type cyclonedx \
--certificate-identity-regexp '^https://github.com/acme/api/\.github/workflows/sbom\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"${IMAGE}@${DIGEST}" > /dev/null && echo verified
Add --signer-workflow acme/api/.github/workflows/sbom.yml to the gh command if you want to reject attestations made by any other workflow in the organisation.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Unsupported SBOM format. Must be valid SPDX or CycloneDX JSON. from actions/attest |
SBOM is SPDX 3.0, Syft JSON, or XML; the action checks for spdxVersion+SPDXID or bomFormat+serialNumber+specVersion |
Emit -o spdx-json@2.3 or -o cyclonedx-json@1.6 and point sbom-path at that file |
SBOM file exceeds maximum allowed size |
SBOM over the 16 MB limit, common for large monorepo images | Attest the CycloneDX file, which is usually smaller, or split the image; keep the full file as an artifact |
Attestation step fails with a permissions or Resource not accessible by integration error |
Missing id-token: write, attestations: write or artifact-metadata: write on the job |
Add the permissions block from Step 2 at job level |
| Attestations unavailable on a private repository | Plan is not GitHub Enterprise Cloud | Use the cosign alternative in Step 4 |
repository name must be lowercase at push |
github.repository contains capitals |
Lowercase it, as in the ${GITHUB_REPOSITORY,,} step |
Syft fails with unauthorized or cannot find the image |
Syft ran before login, or looked for a local daemon image | Run docker/login-action first and pass --from registry |
| SBOM contents change between identical builds | Unpinned Syft version, or scanning a moving tag | Pin syft-version and scan image@sha256:... |
| Dependency-Track returns 400 Bad Request | SBOM failed schema validation, often an SPDX file sent to a CycloneDX-only endpoint | Upload sbom.cdx.json, validate with jq '.bomFormat' first |
| Dependency-Track returns 401 or 403 | Key lacks BOM_UPLOAD, or PROJECT_CREATION_UPLOAD with auto-create |
Grant the permissions to the service account or team |
gh attestation verify finds nothing |
Default predicate is SLSA provenance, or wrong --owner |
Pass --predicate-type as in Step 7 and log in to the registry |
FAQ
Should I use anchore/sbom-action or run Syft directly?
Both run Syft. The action is shorter and can upload release assets and submit to GitHub's dependency graph; running the binary from download-syft lets you pin output spec versions explicitly. I use the binary in release pipelines because I want the SPDX and CycloneDX versions written down in the workflow.
Is actions/attest-sbom deprecated?
Yes. Its README says it is being deprecated in favour of actions/attest, and from v4 it is a wrapper over that action with compatible inputs. New workflows should call actions/attest with sbom-path.
Do I need both SPDX and CycloneDX?
Only if you have consumers for both. Dependency-Track and most security tools want CycloneDX; legal teams and some contracts ask for SPDX. Generating both from one Syft scan costs seconds, so I produce both and attest both.
Should I generate the SBOM from source or from the image?
From the artifact you ship. A source scan reads lockfiles and misses OS packages and anything added at build time. CISA's 2026 minimum elements add a generation-context field precisely because the two disagree; an image-by-digest SBOM is the one that matches what runs.
What I check first
When an SBOM pipeline misbehaves, the first thing I check is whether every step really used the same digest, because a tag sneaking into one step explains most "the SBOM is wrong" reports. Second, the pinned versions: Syft, Grype and the spec versions in the -o flags. Third, whether anyone ever verified an attestation; an unverified signature is decoration. In production I keep the scan gate last, run Dependency-Track on its own Postgres, and review VEX files like code. The official references are the Syft docs, actions/attest, gh attestation verify, Grype CLI reference and the Dependency-Track upload guide.
Related: How to Scan Container Images with Trivy and Grype, How to Sign and Verify Container Images with Cosign Keyless and Sigstore Explained (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Add Semgrep and CodeQL to GitHub Actions (2026 Guide)
Step-by-step 2026 guide to Semgrep CE 1.183 and CodeQL Action v4.39 in GitHub Actions: default vs advanced setup, pinned workflows, SARIF upload with categories, PR-only diff scans, custom rules with tests, suppressing false positives, merge gates and fixes for common errors.
13 minOct 31, 2026How to Sign and Verify Container Images with Cosign Keyless (2026)
Step-by-step: sign container images with cosign v3.1.4 keyless in GitHub Actions, verify with certificate identity flags, attach SBOM and SLSA attestations, enforce signatures in Kubernetes with Kyverno 1.19 ImageValidatingPolicy or Sigstore policy-controller, and fix common errors.
11 minOct 20, 2026How to Scan Container Images with Trivy and Grype (2026)
Step-by-step guide to scanning container images with Trivy v0.76 and Grype v0.121: verified installs after the March 2026 Trivy compromise, image and SBOM scans, CI gates on fixable criticals, .trivyignore, VEX, and fixes for DB download errors.
12 min