GitHub Action
The Metbcy/bomdrift action is a composite action (no Docker), which
keeps PR-comment latency low — typically 5–10s on a warm runner versus
30s+ for a Docker container action.
Quick start (zero-config, v0.5+)
On a pull_request workflow, the action defaults to comparing the PR’s
base branch against the PR’s head SHA — no checkout step, no Syft step,
no SBOM-path wiring needed:
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: Metbcy/bomdrift@v1
That’s it. The action checks out both refs into opaque sibling paths, generates CycloneDX-JSON SBOMs via Syft (installed automatically and cached across job runs), and posts the rendered diff as an upserted PR comment.
For a repo-owned policy, run bomdrift init once and commit the generated
.bomdrift.toml plus workflows. The action auto-loads .bomdrift.toml
from the repo root when present, or you can pass
config: .bomdrift.toml explicitly.
If you already produce SBOMs through a non-Syft toolchain — Trivy,
SPDX-tools, an in-house generator — supply the file paths via the
before-sbom / after-sbom inputs instead. The advanced flow below
documents that path; both flows continue to be supported in v1.
Inputs
The action exposes the full bomdrift CLI surface as inputs (v0.9.7+ input parity, current as of v0.9.9). For the canonical flag semantics see CLI reference; the tables below document only the action-side wrapper. Empty defaults mean “don’t pass the flag” — bomdrift then uses its own CLI/config defaults.
What’s new in v0.9.7
These inputs are newly exposed (the underlying CLI flags shipped earlier):
- VEX:
vex,emit-vex,vex-author,vex-default-justification - License policy:
allow-licenses,deny-licenses,allow-exception,deny-exception,allow-ambiguous-licenses - Enrichment toggles:
no-epss,no-kev,no-registry - Failure thresholds:
fail-on-epss - Calibration knobs:
recently-published-days,typosquat-similarity-threshold,young-maintainer-days,cache-ttl-hours,multi-major-delta(new CLI flag in v0.9.7) - Attestation:
before-attestation,after-attestation,cosign-identity,cosign-issuer,require-attestation - Plugins:
plugin
Before v0.9.7 these had to be driven through .bomdrift.toml or a direct
cargo install invocation. The config-file path remains supported and is
still preferred for repo-wide policy.
Core: refs, paths, SBOMs
| Input | Type | Default | Description |
|---|---|---|---|
before-ref | string | ${{ github.event.pull_request.base.ref }} | Git ref / SHA to check out as the “before” side. Default works on pull_request events. |
after-ref | string | ${{ github.event.pull_request.head.sha }} | Git ref / SHA for the “after” side. |
path | string | . | Subdirectory of the checked-out ref to scan with Syft (monorepos: path: services/api). |
before-sbom | string (path) | '' | Pre-generated “before” SBOM. Bypasses the in-action Syft invocation. |
after-sbom | string (path) | '' | Pre-generated “after” SBOM. |
format | enum | auto | Force input format: auto/cdx/spdx/syft. Maps to --format. |
Output
| Input | Type | Default | Description |
|---|---|---|---|
output | enum | markdown | Output format: terminal/markdown/json/sarif. PR comments require markdown. Maps to --output. |
comment-on-pr | bool | true | Post the rendered diff as a PR comment on pull_request events. |
comment-size-limit | number | 60000 | Bytes. Above this size, the PR-comment body is re-rendered with --summary-only. 0 disables the fallback. |
findings-only | bool | false | Markdown-only. Maps to --findings-only. |
upload-to-code-scanning | bool | false | Upload SARIF to GitHub Code Scanning. Requires output: sarif. |
github-token | string | ${{ github.token }} | Token used to post PR comments. |
Suppression and policy
| Input | Type | Default | Description |
|---|---|---|---|
config | string (path) | '' | Path to .bomdrift.toml. Empty auto-loads from the repo root when present. Maps to --config. |
baseline | string (path) | '' | Pre-captured bomdrift diff --output json snapshot to suppress against. Maps to --baseline. |
vex | string (multi-line paths) | '' | OpenVEX documents to consume; one path per line, each becomes a repeated --vex. |
emit-vex | string (path) | '' | Path to write a freshly emitted OpenVEX document. Maps to --emit-vex. |
vex-author | string | '' | Author identity for the emitted OpenVEX. Maps to --vex-author. |
vex-default-justification | string | '' | OpenVEX not_affected justification ID applied by default. Maps to --vex-default-justification. |
License policy
| Input | Type | Default | Description |
|---|---|---|---|
allow-licenses | string (comma list) | '' | SPDX expressions to allow. Maps to --allow-licenses. |
deny-licenses | string (comma list) | '' | SPDX expressions to deny. Maps to --deny-licenses. |
allow-exception | string (comma list) | '' | SPDX exception identifiers to allow inside WITH clauses. v0.9.7 refines compound-expression inheritance. Maps to --allow-exception. |
deny-exception | string (comma list) | '' | SPDX exception identifiers to deny. Maps to --deny-exception. |
allow-ambiguous-licenses | bool | false | Treat unresolved license expressions as allowed. Maps to --allow-ambiguous-licenses. |
Enrichment toggles
| Input | Type | Default | Description |
|---|---|---|---|
no-epss | bool | false | Disable EPSS exploit-likelihood enrichment. Maps to --no-epss. |
no-kev | bool | false | Disable CISA KEV enrichment. Maps to --no-kev. |
no-registry | bool | false | Disable registry / maintainer-age enrichment (no network calls to package registries). Maps to --no-registry. |
Calibration knobs
| Input | Type | Default | Description |
|---|---|---|---|
recently-published-days | number | '' | “Recently published” maintainer-age window. Maps to --recently-published-days. |
typosquat-similarity-threshold | number (0.0–1.0) | '' | Damerau-Levenshtein threshold for typosquat detection. Maps to --typosquat-similarity-threshold. |
young-maintainer-days | number | '' | Age below which a maintainer is flagged as “young”. Maps to --young-maintainer-days. |
cache-ttl-hours | number | '' | TTL for the on-disk enrichment cache. Maps to --cache-ttl-hours. |
multi-major-delta | number (≥1) | '' | Major-version delta at or above which an upgrade is flagged as multi-major (CLI default 2). Maps to --multi-major-delta. New in v0.9.7. |
Failure thresholds
| Input | Type | Default | Description |
|---|---|---|---|
fail-on | enum | none | Trip exit 2 on findings of the configured kind: none/cve/critical-cve/typosquat/license-change/any. The PR comment is still posted on a tripped run. |
fail-on-epss | number (0.0–1.0) | '' | Trip exit 2 when any new advisory has an EPSS score at or above this value. Maps to --fail-on-epss. |
max-added | number | '' | Exit 2 when more than this many dependencies are added. |
max-removed | number | '' | Exit 2 when more than this many dependencies are removed. |
max-version-changed | number | '' | Exit 2 when more than this many dependencies change version. |
OCI attestation
| Input | Type | Default | Description |
|---|---|---|---|
before-attestation | string (OCI ref) | '' | OCI reference for the cosign attestation covering the “before” SBOM. Maps to --before-attestation. |
after-attestation | string (OCI ref) | '' | OCI reference for the “after” SBOM attestation. Maps to --after-attestation. |
cosign-identity | string (regex) | '' | Regex matched against the cosign certificate identity (--certificate-identity-regexp). Maps to --cosign-identity. |
cosign-issuer | string (URL) | '' | OIDC issuer URL for keyless cosign verification. Maps to --cosign-issuer. |
require-attestation | bool | false | Fail when either side is missing a verified attestation. Maps to --require-attestation. |
For air-gapped / self-hosted Sigstore deployments, see Air-gapped / self-hosted Sigstore.
Plugins
| Input | Type | Default | Description |
|---|---|---|---|
plugin | string (multi-line paths) | '' | Plugin manifests to load; one path per line, each becomes a repeated --plugin. See Plugins. |
Release verification
| Input | Type | Default | Description |
|---|---|---|---|
verify-signatures | bool | true | Install cosign and verify the bomdrift release archive’s Sigstore signature. Set false on trusted mirrors / cached runners (saves ~15s). When true and cosign is missing, the action fails loudly. |
Outputs
The action does not declare formal outputs. Its side effects are:
- The rendered diff is written to stdout (visible in the workflow run log
under the
Run bomdriftstep). - When
output == markdownandGITHUB_STEP_SUMMARYis set, the rendered diff is appended to the step summary so reviewers can see it without a PR-comment posting permission. - On
pull_requestevents withcomment-on-pr: true, the rendered diff is upserted into a single PR comment marked<!-- bomdrift:diff -->. Subsequent pushes update the same comment instead of accumulating new ones (peter-evans/create-or-update-comment-style upsert). - When
fail-onor a diff budget trips, the action exits with code 2 — but only after the PR comment has been posted, so reviewers see the findings even when the workflow step fails.
Common patterns
Repo policy file
Use .bomdrift.toml when you want the policy in version control instead
of repeated YAML inputs:
[diff]
fail_on = "critical-cve"
baseline = ".bomdrift/baseline.json"
findings_only = true
max_added = 25
max_version_changed = 10
- uses: Metbcy/bomdrift@v1
with:
config: .bomdrift.toml
Explicit action inputs still override the config-backed defaults for one-off workflows.
Bring your own SBOMs (advanced / pre-v0.5 flow)
When the SBOMs come from a non-Syft toolchain (Trivy, SPDX-tools, proprietary scanners) or you already generate them in an earlier job step, supply both paths explicitly. The action skips the in-action Syft invocation entirely:
- uses: actions/checkout@v4
- uses: anchore/sbom-action@v0
with: { path: ., output-file: after.json }
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.base.ref }}, path: base }
- uses: anchore/sbom-action@v0
with: { path: base, output-file: before.json }
- uses: Metbcy/bomdrift@v1
with: { before-sbom: before.json, after-sbom: after.json }
This is the v0.4-era “manual” pattern. It still works in v0.5 — the
before-sbom / after-sbom inputs were required: true in v0.4 and
became required: false in v0.5; nothing else changed about how they
behave. Existing v0.4 workflows continue to function unchanged after a
@v1 tag bump.
Block the merge on critical findings
- uses: Metbcy/bomdrift@v1
with:
before-sbom: before.json
after-sbom: after.json
fail-on: critical-cve
critical-cve filters on severity >= High per the OSV-fetched severity
(see OSV.dev CVE lookup). typosquat,
license-change, and any are also accepted thresholds — see
--fail-on.
Self-hosted / trusted-mirror runners
- uses: Metbcy/bomdrift@v1
with:
before-sbom: before.json
after-sbom: after.json
verify-signatures: false # ~15s faster, skips cosign-installer
This is appropriate when:
- You’re running on self-hosted runners with a hardened image you control.
- You’ve pre-pinned the bomdrift archive in your Nexus/Artifactory mirror and verified its signature once at mirror time.
- You’re running in a network-restricted environment where the public Sigstore endpoints aren’t reachable.
When verify-signatures: true and cosign isn’t installed (or the .sig/
.pem aren’t on the release), the action fails loudly rather than
silently degrading — that’s the whole point of the explicit opt-out.
Big monorepo with massive SBOMs
If bomdrift diff rendered output exceeds GitHub’s 65,536-char comment-body
cap, the v0.3 size fallback re-renders with --summary-only for the PR
comment and keeps the full body in the workflow step summary:
- uses: Metbcy/bomdrift@v1
with:
before-sbom: before.json
after-sbom: after.json
comment-size-limit: 60000 # default; tune for GHE with raised limits
Set comment-size-limit: 0 to disable the fallback entirely and let
GitHub return a 422 on oversized comments (rarely what you want).
Diff-only (no PR comment)
Useful for SARIF uploads, third-party comment posting, or when you just want the diff in the step summary:
- uses: Metbcy/bomdrift@v1
with:
before-sbom: before.json
after-sbom: after.json
output: sarif
comment-on-pr: false
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: bomdrift.sarif }
The output: sarif produces SARIF v2.1.0 with stable rule IDs (see
Output formats).
Comment-driven suppression bridges (other forges)
The comment-suppress companion sub-action is GitHub-only — it relies
on the issue_comment workflow event. For GitLab, Bitbucket Cloud,
and Azure DevOps, bomdrift ships parallel Cloudflare Worker bridges
that listen on each forge’s webhook, validate the trigger, and dispatch
the equivalent bomdrift baseline add --from-comment "<body>" run on
the underlying CI:
examples/gitlab-ci/comment-bridge/(v0.9+)examples/bitbucket-pipelines/comment-bridge/(v0.9.5+)examples/azure-devops/comment-bridge/(v0.9.5+)
Each bridge enforces the same five guards: webhook secret /
HMAC verification, event-type filter, repo / project allowlist,
commenter-permission check, and a PR-context guard. The
/bomdrift suppress <ID> [reason: …] grammar is identical across all
four SCMs and shares a single regex (scripts/parse-suppress-comment.sh)
so behavior cannot drift. See the per-forge chapters
GitLab CI · Bitbucket ·
Azure DevOps for setup.
Action permissions
pull-requests: write is required when comment-on-pr: true (the
default). Without it, the comment-upsert step fails with a 403; the
action’s exit code remains the bomdrift exit (so a fail-on or budget
trip still fails the workflow correctly).
contents: read is required so the action’s internal actions/checkout
steps (zero-config flow) can fetch both refs. In the bring-your-own-SBOMs
flow it’s still required by whichever step generates the SBOMs upstream.
What the action does (v0.5+)
When the zero-config flow runs (no explicit before-sbom / after-sbom):
- Two sibling checkouts of
before-refandafter-refinto${{ github.workspace }}/__bomdrift_beforeand__bomdrift_after. Both withfetch-depth: 1andpersist-credentials: false. Skipped for whichever side has a pre-supplied SBOM path. - Syft installed via
anchore/sbom-action/download-syft@v0. Cached across job runs in the runner’s tool cache. syft scan dir:...against each checkout’s${path}subtree, producing CycloneDX-JSON into a tempfile under$RUNNER_TEMP. The bomdrift parser dropsEcosystem::Other("file")pseudo-components that Syft’s directory cataloger emits — set--include-file-components(CLI) or pass a pre-generated SBOM viabefore-sbom/after-sbomto bypass.bomdrift diffruns as in the v0.4 flow, and the upsert + step summary plumbing is unchanged.
The new behavior costs about 30 MB of one-time tool cache and 3–5s of cold-cache wall time per first invocation. Subsequent runs in the same job (or in repos that share the runner’s tool cache) reuse Syft.
Monorepo setup
When a single repo owns N services with independent dependency trees
(services/api, services/worker, apps/web, …), running one
bomdrift job per service gives each PR a focused, per-service comment
without merging unrelated diff churn into a single 65k-char wall.
Pattern A — path: per matrix entry
The simplest setup uses a job matrix and the action’s path input:
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
diff:
strategy:
fail-fast: false
matrix:
service: [api, worker, web]
runs-on: ubuntu-latest
steps:
- uses: Metbcy/bomdrift@v1
with:
path: services/${{ matrix.service }}
fail-on: critical-cve
Each matrix leg posts (or upserts) its own PR comment, distinguished
by the rendered title (e.g. “SBOM diff — services/api”). The
<!-- bomdrift:diff --> upsert marker is namespaced internally by
path:, so leg N’s comment doesn’t clobber leg N-1’s.
fail-fast: false is recommended: a vulnerability in worker shouldn’t
hide an emergent api finding from the same PR.
Pattern B — share a baseline across services
Most monorepos do want one shared exception list (the same false positive will show up in any service that depends on the same package). Point each leg at the same file:
- uses: Metbcy/bomdrift@v1
with:
path: services/${{ matrix.service }}
baseline: .bomdrift/baseline.json
The baseline file is keyed by (purl_with_version, advisory_id) — see
Match keys — so a suppression for
pkg:npm/colour-print@2.1.0 covers every service that pulls in that
exact version. New versions still surface (intentional; that’s the
point of the version-pinned key).
When services pin different versions of the same dep, you’ll get per-version baseline entries. That’s working-as-intended — a known-fine finding at v1.0.0 should still get a fresh review at v1.1.0.
Pattern C — per-service .bomdrift.toml
When the policy itself differs (worker has a stricter fail-on,
docs-site has a generous max-added), drop a .bomdrift.toml per
service:
- uses: Metbcy/bomdrift@v1
with:
path: services/${{ matrix.service }}
config: services/${{ matrix.service }}/.bomdrift.toml
The auto-discovery only checks the repo root, so an explicit
config: is required for nested files.
What to scope per service vs. globally
| Setting | Scope | Why |
|---|---|---|
fail-on, max-* budgets | Per-service | Worker’s risk surface ≠ web’s |
baseline | Shared | Same false positives across services |
comment-on-pr, output | Per-service | Diff-only legs vs. PR-comment legs |
verify-signatures | Global | Runner-image property, not service property |
Action-broke troubleshooting checklist
When a previously-working bomdrift action job starts failing — typically right after a merge to your default branch, a token rotation, or a runner-image upgrade — work through these in order. Each row is one symptom, one fix so you can grep your job log for the symptom and land on the recipe.
| Symptom (in the job log) | Likely cause | Fix |
|---|---|---|
403 Resource not accessible by integration on the comment-upsert step | pull-requests: write permission missing on the workflow / job | Add permissions: { pull-requests: write, contents: read } at the workflow or job level. PR comments need pull-requests: write; the action’s internal checkouts need contents: read. |
Forks cannot post PR comments warning, exit 0 | PR is from a fork; default GITHUB_TOKEN on pull_request events is read-only | Switch the trigger to pull_request_target (and harden — see GitHub’s guidance), or accept that fork PRs only get the workflow step summary, not a PR comment. |
Could not find SBOM at services/api after a green earlier run | Default branch protection bumped the merge-base; before-ref now points at a commit that predates the services/api directory | Either move the path: value to match the new layout, or pin before-ref explicitly to a known-good commit (before-ref: main). |
cosign: signature verification failed after a release-archive rotation | Cached release archive in the runner’s tool cache is stale and predates a rotation | Bump to the latest patch tag (e.g. Metbcy/bomdrift@v1 re-resolves to the floating tag), or set verify-signatures: false on a self-hosted runner you’ve pinned manually. |
path: services/api warning + empty SBOM | The path doesn’t exist post-checkout — typo, or the directory was renamed in before-ref only | bomdrift v0.7+ surfaces an actionable error pointing at this exact case. See the monorepo section for the matrix recipe; double-check ${{ matrix.service }} substitution. |
| “Comment exceeds 65,536 characters” 422 from GitHub | A massive diff blew past the size cap; the v0.3 fallback to --summary-only was disabled (comment-size-limit: 0) | Re-enable the fallback (drop comment-size-limit to use the default, or set it to 60000). The full body is preserved in the workflow step summary. |
| Action runs, no PR comment appears, exit 0 | Workflow event isn’t pull_request (the comment path is gated on PR events), or comment-on-pr: false was set explicitly | For push/schedule events, the comment path is intentionally skipped — use the step summary or upload the markdown as an artifact. |
If you hit a failure mode not in the table above, please open an issue with the failing job log — the troubleshooting table grows from real reports.