From d8c1beb0fcc893262596bcb7675d54fdd391ea29 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 3 Jul 2026 00:18:56 +0200 Subject: [PATCH 1/2] Skip templated dependency audit on Dependabot PRs `make audit` reports advisories against the whole lockfile, so in generated projects a newly published advisory fails every open Dependabot PR regardless of its content and deadlocks auto-merge. Condition the audit step on the actor not being Dependabot, and add a templated weekly scheduled audit workflow as the compensating control so anything that slips through surfaces within days. Human pull requests keep the full audit gate. --- template/.github/workflows/audit.yml.jinja | 50 ++++++++++++++++++++++ template/.github/workflows/ci.yml.jinja | 5 +++ 2 files changed, 55 insertions(+) create mode 100644 template/.github/workflows/audit.yml.jinja diff --git a/template/.github/workflows/audit.yml.jinja b/template/.github/workflows/audit.yml.jinja new file mode 100644 index 0000000..e1ca3db --- /dev/null +++ b/template/.github/workflows/audit.yml.jinja @@ -0,0 +1,50 @@ +name: Scheduled dependency audit + +# CI skips `make audit` on Dependabot pull requests so that newly published +# advisories against the existing lockfile cannot block unrelated dependency +# bumps. This scheduled run is the compensating control: it audits the +# default branch weekly so anything that slips through surfaces within days. + +on: + schedule: + - cron: '11 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + {% if use_rust %} + env: + CARGO_TERM_COLOR: always + {% endif %} + steps: + - name: Check out repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + {% if use_rust %} + - name: Set up Rust + uses: leynos/shared-actions/.github/actions/setup-rust@455d9ed03477c0026da96c2541ca26569a74acac + with: + toolchain: stable + + - name: Install cargo-audit + env: + RUSTFLAGS: "" + run: cargo install --locked cargo-audit + + {% endif %} + - name: Audit dependencies + run: make audit diff --git a/template/.github/workflows/ci.yml.jinja b/template/.github/workflows/ci.yml.jinja index c9ec18a..a911b32 100644 --- a/template/.github/workflows/ci.yml.jinja +++ b/template/.github/workflows/ci.yml.jinja @@ -78,7 +78,12 @@ jobs: - name: Run typechecker run: make typecheck + # Dependency audits report advisories against the whole lockfile, so a + # newly published advisory would fail every Dependabot PR regardless of + # content and deadlock auto-merge. The scheduled audit workflow covers + # Dependabot merges instead. - name: Audit dependencies + if: github.actor != 'dependabot[bot]' run: make audit - name: Test and Measure Coverage From bb0ffbb73207abd5fa209e698143368eecc20228 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 3 Jul 2026 23:11:25 +0200 Subject: [PATCH 2/2] Cover scheduled audit workflow review feedback Add a timeout to the scheduled audit job and assert the CI audit step's Dependabot guard structurally. Cover the generated scheduled audit workflow for both pure-Python and Rust-enabled renders so its trigger, permissions, timeout, and Rust-specific steps stay under test. --- template/.github/workflows/audit.yml.jinja | 1 + tests/helpers/ci_contracts.py | 11 ++++ tests/test_audit.py | 65 ++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/template/.github/workflows/audit.yml.jinja b/template/.github/workflows/audit.yml.jinja index e1ca3db..0ba0043 100644 --- a/template/.github/workflows/audit.yml.jinja +++ b/template/.github/workflows/audit.yml.jinja @@ -16,6 +16,7 @@ permissions: jobs: audit: runs-on: ubuntu-latest + timeout-minutes: 30 {% if use_rust %} env: CARGO_TERM_COLOR: always diff --git a/tests/helpers/ci_contracts.py b/tests/helpers/ci_contracts.py index 2226dec..34107d4 100644 --- a/tests/helpers/ci_contracts.py +++ b/tests/helpers/ci_contracts.py @@ -131,6 +131,17 @@ def _assert_ci_workflow_contracts( assert "make audit" in ci_workflow, ( "expected generated CI workflow to run the dependency audit gate" ) + audit_steps = [ + step + for step in steps + if isinstance(step, dict) and step.get("name") == "Audit dependencies" + ] + assert len(audit_steps) == 1, ( + "expected generated CI workflow to include one dependency audit step" + ) + assert audit_steps[0].get("if") == "github.actor != 'dependabot[bot]'", ( + "expected generated CI audit step to skip Dependabot pull requests" + ) assert "make test WITH_ACT=1" not in ci_workflow, ( "expected generated main CI workflow to leave act validation to a " "separate workflow" diff --git a/tests/test_audit.py b/tests/test_audit.py index c229ea7..00f9f79 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -15,6 +15,11 @@ import pytest from pytest_copier.plugin import CopierFixture +from tests.helpers.generated_files import ( + parse_yaml_mapping, + require_mapping, + require_sequence, +) from tests.helpers.rendering import render_project @@ -93,6 +98,66 @@ def test_generated_audit_target_runs_expected_tools( ) +@pytest.mark.parametrize( + ("target_dir", "project_name", "package_name", "use_rust"), + [ + ("audit-workflow-pure", "AuditWorkflowPure", "audit_workflow_pure", False), + ("audit-workflow-rust", "AuditWorkflowRust", "audit_workflow_rust", True), + ], +) +def test_generated_audit_workflow_has_expected_contract( + copier: CopierFixture, + tmp_path: Path, + target_dir: str, + project_name: str, + package_name: str, + use_rust: bool, +) -> None: + """Validate generated scheduled audit workflow structure.""" + project = render_project( + tmp_path / target_dir, + copier, + project_name=project_name, + package_name=package_name, + use_rust=use_rust, + ) + workflow_text = (project.path / ".github/workflows/audit.yml").read_text( + encoding="utf-8" + ) + workflow = parse_yaml_mapping(workflow_text, "audit workflow") + workflow_triggers = workflow.get(True) + assert isinstance(workflow_triggers, dict), ( + "expected generated audit workflow to include triggers" + ) + schedule = require_sequence(workflow_triggers, "schedule", "audit workflow trigger") + assert schedule == [{"cron": "11 7 * * 1"}], ( + "expected generated audit workflow to run weekly" + ) + permissions = require_mapping(workflow, "permissions", "audit workflow") + assert permissions == {"contents": "read"}, ( + "expected generated audit workflow to use read-only contents permission" + ) + jobs = require_mapping(workflow, "jobs", "audit workflow") + audit_job = require_mapping(jobs, "audit", "audit workflow jobs") + assert audit_job.get("timeout-minutes") == 30, ( + "expected generated audit workflow to cap audit job runtime" + ) + steps = require_sequence(audit_job, "steps", "audit workflow audit job") + step_names = [step.get("name") for step in steps if isinstance(step, dict)] + assert "Audit dependencies" in step_names, ( + "expected generated audit workflow to run dependency audit" + ) + rust_step_names = {"Set up Rust", "Install cargo-audit"} + if use_rust: + assert rust_step_names.issubset(step_names), ( + "expected Rust audit workflow to include Rust audit setup" + ) + else: + assert rust_step_names.isdisjoint(step_names), ( + "expected pure-Python audit workflow to omit Rust audit setup" + ) + + def _write_fake_uv(bin_dir: Path, command_log: Path) -> Path: """Write a fake uv executable that records audit invocations.""" bin_dir.mkdir(parents=True, exist_ok=True)