From 0b06600e339ad5ae907e6efa0cfb22e11b69e309 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 3 Jul 2026 00:18:56 +0200 Subject: [PATCH 1/5] 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 eb5d20f..ce316fc 100644 --- a/template/.github/workflows/ci.yml.jinja +++ b/template/.github/workflows/ci.yml.jinja @@ -82,7 +82,12 @@ jobs: - name: Check spelling run: make spelling + # 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 # Coverage runs on pull requests only. Pushes to main upload their From 918064ce403d265cf92b0b3b9db0b34880ba82cd Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 3 Jul 2026 23:11:25 +0200 Subject: [PATCH 2/5] 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 edc925f..b1b80ab 100644 --- a/tests/helpers/ci_contracts.py +++ b/tests/helpers/ci_contracts.py @@ -273,6 +273,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) From fc34b459d0d5a0cab69c32e0419302f0a908cb7a Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 18 Jul 2026 18:00:26 +0200 Subject: [PATCH 3/5] Document Dependabot audit policy Explain that pull-request CI skips dependency auditing for Dependabot and that the weekly scheduled workflow audits the default branch as the compensating control. --- docs/developers-guide.md | 8 ++++++-- template/docs/developers-guide.md | 5 ++++- template/docs/users-guide.md.jinja | 6 ++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e982774..999c257 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -31,12 +31,16 @@ template itself. ## Parent CI Workflows -The parent repository uses two separate GitHub Actions workflows to keep +The parent repository uses three separate GitHub Actions workflows to keep Docker-dependent tests isolated from the standard template test gate: - `.github/workflows/ci.yml` runs `make test` and `make spelling` on every push to `main` and on all pull requests. It installs `markdownlint-cli2` and - `mbake` at pinned versions. + `mbake` at pinned versions, and skips `make audit` for Dependabot pull + requests with + `if: github.actor != 'dependabot[bot]'`. +- `.github/workflows/audit.yml` runs `make audit` on a weekly schedule against + the default branch. - `.github/workflows/act-validation.yml` runs `make test WITH_ACT=1`. It additionally downloads the `act` binary at a pinned `ACT_VERSION`, verifies its SHA-256 checksum before extraction, and confirms Docker availability via diff --git a/template/docs/developers-guide.md b/template/docs/developers-guide.md index f92893d..15ae4a2 100644 --- a/template/docs/developers-guide.md +++ b/template/docs/developers-guide.md @@ -37,10 +37,13 @@ actions under `.github/`. sets up Python 3.13, installs `uv`, validates the generated `Makefile` with `mbake`, runs `make build`, `make check-fmt`, `make lint` (Ruff + `interrogate --fail-under 100 $(PYTHON_TARGETS)` + Pylint), - `make typecheck`, `make spelling`, and `make audit`, then delegates coverage + `make typecheck`, `make spelling`, and `make audit` except for Dependabot pull + requests via `if: github.actor != 'dependabot[bot]'`, then delegates coverage generation to the shared coverage action. When the Rust extension is enabled, it also sets up Rust, installs Rust lint and test tools, and passes `rust_extension/Cargo.toml` to coverage. +- `.github/workflows/audit.yml` runs `make audit` against the default branch + weekly as the compensating control for the Dependabot CI bypass. - `.github/workflows/act-validation.yml` runs rendered workflow validation in a separate workflow. It installs `act`, checks Docker availability, and runs `make test WITH_ACT=1` outside the coverage path. diff --git a/template/docs/users-guide.md.jinja b/template/docs/users-guide.md.jinja index c0972b1..3c7607a 100644 --- a/template/docs/users-guide.md.jinja +++ b/template/docs/users-guide.md.jinja @@ -44,8 +44,10 @@ when it is not already available. Run `make audit` to check generated project dependencies for known vulnerabilities. All generated projects run `pip-audit` against the Python -environment created by `uv sync --group dev`. Rust-enabled projects also run -`cargo audit` from the `rust_extension` crate directory. +environment created by `uv sync --group dev`. CI skips `make audit` for +Dependabot pull requests; a weekly scheduled audit on the default branch is the +compensating control. Rust-enabled projects also run `cargo audit` from the +`rust_extension` crate directory. ## Rust Test Behaviour From f4c709aa500e5ba24d9665d7751e50719941da27 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 19 Jul 2026 19:18:34 +0200 Subject: [PATCH 4/5] Close scheduled audit contract gaps Document the Dependabot exception in the parent CI strategy and assert the scheduled audit workflow's manual trigger and exact audit command. --- docs/developers-guide.md | 7 +++++-- tests/test_audit.py | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 999c257..eb9ff4e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -77,8 +77,11 @@ override pins without editing target recipes. `template/.github/workflows/ci.yml.jinja` mirrors the generated local gates. It sets up Python, optionally sets up Rust, runs `make check-fmt`, `make lint`, -`make typecheck`, `make spelling`, and `make audit`, then delegates coverage to -`leynos/shared-actions/.github/actions/generate-coverage`. +`make typecheck`, `make spelling`, and `make audit` except when +`github.actor == 'dependabot[bot]'`, then delegates coverage to +`leynos/shared-actions/.github/actions/generate-coverage`. The scheduled +`.github/workflows/audit.yml` workflow audits the default branch weekly as the +compensating control. The shared coverage action runs Python coverage through xdist-backed SlipCover support. Generated pytest discovery is therefore constrained to the top-level diff --git a/tests/test_audit.py b/tests/test_audit.py index 00f9f79..763f2ff 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -129,6 +129,9 @@ def test_generated_audit_workflow_has_expected_contract( assert isinstance(workflow_triggers, dict), ( "expected generated audit workflow to include triggers" ) + assert "workflow_dispatch" in workflow_triggers, ( + "expected generated audit workflow to support manual dispatch" + ) schedule = require_sequence(workflow_triggers, "schedule", "audit workflow trigger") assert schedule == [{"cron": "11 7 * * 1"}], ( "expected generated audit workflow to run weekly" @@ -143,10 +146,18 @@ def test_generated_audit_workflow_has_expected_contract( "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" + audit_steps = [ + step + for step in steps + if isinstance(step, dict) and step.get("name") == "Audit dependencies" + ] + assert len(audit_steps) == 1, ( + "expected generated audit workflow to include one dependency audit step" ) + assert audit_steps[0].get("run") == "make audit", ( + "expected generated audit workflow to run the dependency audit target" + ) + step_names = [step.get("name") for step in steps if isinstance(step, dict)] rust_step_names = {"Set up Rust", "Install cargo-audit"} if use_rust: assert rust_step_names.issubset(step_names), ( From 66657a27d3cb567441e060acd55bba7efffc05ee Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 21 Jul 2026 10:58:39 +0200 Subject: [PATCH 5/5] Tighten scheduled audit workflow test assertions Assert the exact `cargo install --locked cargo-audit` run value for the generated "Install cargo-audit" step instead of only checking the step's presence, so a regression in the install command is caught. Make `use_rust` keyword-only in the contract test signature to keep the boolean parameter off the positional interface (Ruff FBT001) while preserving pytest's name-based parametrization binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_audit.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_audit.py b/tests/test_audit.py index 763f2ff..198283c 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -111,6 +111,7 @@ def test_generated_audit_workflow_has_expected_contract( target_dir: str, project_name: str, package_name: str, + *, use_rust: bool, ) -> None: """Validate generated scheduled audit workflow structure.""" @@ -163,6 +164,17 @@ def test_generated_audit_workflow_has_expected_contract( assert rust_step_names.issubset(step_names), ( "expected Rust audit workflow to include Rust audit setup" ) + install_steps = [ + step + for step in steps + if isinstance(step, dict) and step.get("name") == "Install cargo-audit" + ] + assert len(install_steps) == 1, ( + "expected Rust audit workflow to include one cargo-audit install step" + ) + assert install_steps[0].get("run") == "cargo install --locked cargo-audit", ( + "expected Rust audit workflow to install cargo-audit with a locked build" + ) else: assert rust_step_names.isdisjoint(step_names), ( "expected pure-Python audit workflow to omit Rust audit setup"