diff --git a/.github/workflows/sbom-generation.yml b/.github/workflows/sbom-generation.yml index b62f0b3d3..b56a261be 100644 --- a/.github/workflows/sbom-generation.yml +++ b/.github/workflows/sbom-generation.yml @@ -19,9 +19,19 @@ # NOTE: contents: write is required for release-asset upload and for the # dependency submission API. Fork PR heads run without write and simply skip # those side effects; the artifact is still produced. +# +# NOTE on the push trigger: it exists so the DEFAULT BRANCH has a dependency +# snapshot. dependency-review compares base...head in the dependency graph; with +# PR-only runs the base commit never has one, so every comparison reports "the +# number of snapshots compared for the base SHA (0) and the head SHA (1) do not +# match" and the whole dependency set reads as newly added. That re-flags +# pre-existing vulnerabilities on every PR instead of only the ones the PR adds. +# Snapshotting pushes to the default branch gives the comparison a real base. name: SBOM Generation on: + push: + branches: [main, master, develop] pull_request: types: [opened, synchronize, reopened, ready_for_review, closed] branches: [main, master, develop] @@ -29,7 +39,10 @@ on: types: [published] concurrency: - group: sbom-generation-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number || github.event.release.tag_name || github.ref }} + # Final fallback is the SHA, not the ref, so two pushes landing close together + # do not cancel each other: a cancelled push run leaves that commit without a + # snapshot, which is exactly the base-side gap this trigger exists to close. + group: sbom-generation-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number || github.event.release.tag_name || github.sha }} cancel-in-progress: true permissions: diff --git a/tests/test_sbom_generation_push_contract.py b/tests/test_sbom_generation_push_contract.py new file mode 100644 index 000000000..011be9737 --- /dev/null +++ b/tests/test_sbom_generation_push_contract.py @@ -0,0 +1,54 @@ +"""Contracts for default-branch dependency snapshot generation.""" + +from __future__ import annotations + +from pathlib import Path + + +WORKFLOW = Path(".github/workflows/sbom-generation.yml") + + +def _workflow_text() -> str: + """Return the centrally versioned SBOM workflow as UTF-8 text.""" + + return WORKFLOW.read_text(encoding="utf-8") + + +def test_sbom_workflow_snapshots_supported_default_branch_pushes() -> None: + """Default-branch commits must receive dependency graph snapshots.""" + + workflow = _workflow_text() + + assert "on:\n push:\n branches: [main, master, develop]\n" in workflow + assert " pull_request:\n" in workflow + assert " release:\n" in workflow + assert "dependency-snapshot: true" in workflow + + +def test_sbom_push_concurrency_is_bound_to_the_commit_sha() -> None: + """A later default-branch push must not cancel another commit's snapshot.""" + + workflow = _workflow_text() + group_line = next( + line.strip() for line in workflow.splitlines() if line.strip().startswith("group:") + ) + + assert "github.event.release.tag_name || github.sha" in group_line + assert "github.event.release.tag_name || github.ref" not in group_line + + +def test_sbom_job_conditions_keep_push_runs_active_and_closed_prs_inert() -> None: + """Pushes run the snapshot job while closed-PR events only cancel stale work.""" + + workflow = _workflow_text() + + assert ( + "if: github.event_name == 'pull_request' && github.event.action == 'closed'" + in workflow + ) + assert ( + "if: github.event_name != 'pull_request' || github.event.action != 'closed'" + in workflow + ) + assert "generate-sbom:\n" in workflow + assert " contents: write\n" in workflow