diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index c5c613c08..b1161aaaa 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -7,6 +7,19 @@ on: branches: [ develop, master ] workflow_dispatch: +concurrency: + # Workflow-level admission: a job-level group never coalesces while the run + # is queued behind the organization job ceiling. Push scans share one group + # per protected branch so a newer develop/master head retires the older + # scan. workflow_dispatch keeps github.run_id and is not cancelled by a + # sibling. This is a security scan, not merge/release/deploy. + group: >- + bandit-security-scan-${{ github.repository }}-${{ + github.event.pull_request.number || + (github.event_name == 'push' && format('push-{0}', github.ref_name)) || + github.run_id }} + cancel-in-progress: true + permissions: contents: read diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py new file mode 100644 index 000000000..e9b424a56 --- /dev/null +++ b/backend/tests/test_workflow_concurrency.py @@ -0,0 +1,35 @@ +"""Regression tests for GitHub Actions concurrency boundaries.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_workflow(workflow_name: str) -> dict[str, object]: + path = REPO_ROOT / ".github" / "workflows" / workflow_name + assert path.exists(), f"workflow is missing: {workflow_name}" + parsed = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(parsed, dict), ( + f"workflow must parse as a mapping: {workflow_name}" + ) + return parsed + + +def test_bandit_coalesces_pr_and_protected_branch_push_scans() -> None: + """Cancel superseded PR and push scans; keep manual dispatch unique.""" + concurrency = _load_workflow("bandit.yml").get("concurrency") + + assert isinstance(concurrency, dict) + group = concurrency.get("group") + assert isinstance(group, str) + assert "bandit-security-scan-${{ github.repository }}-" in group + assert "github.event.pull_request.number" in group + assert "github.event_name == 'push' && format('push-{0}', github.ref_name)" in group + assert "github.run_id" in group + assert "github.run_attempt" not in group + assert concurrency.get("cancel-in-progress") is True