diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e982774..eb9ff4e 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 @@ -73,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/template/.github/workflows/audit.yml.jinja b/template/.github/workflows/audit.yml.jinja new file mode 100644 index 0000000..0ba0043 --- /dev/null +++ b/template/.github/workflows/audit.yml.jinja @@ -0,0 +1,51 @@ +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 + timeout-minutes: 30 + {% 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 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 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..198283c 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,89 @@ 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" + ) + 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" + ) + 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") + 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), ( + "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" + ) + + 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)