How to Generate an SBOM With Syft in GitHub Actions (2026)

Nov 10, 2026 · 12 min · Ajay Kumar

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.

One job, one digest: build, inventory, sign, monitor, gate 1. Build and push build-push-action pushes to GHCR outputs sha256 digest 2. Syft v1.57.1 syft scan image@digest spdx-json@2.3 cyclonedx-json@1.6 pinned versions 3. Attest actions/attest v4 sbom-path per file push-to-registry Sigstore signed 4. Dependency-Track gh-upload-sbom v4.3 CycloneDX only re-checked daily after release 5. Grype gate scan the SBOM SARIF to code scanning fail on high Every step references image@sha256 from step 1, never a tag. The SBOM describes the bytes you shipped. Gate last so a failing scan still leaves a signed SBOM and a Dependency-Track record for triage.
The pipeline this guide builds, in the order the steps run in a single GitHub Actions job. Versions are the latest GitHub releases as of 10 November 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