Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions template/.github/workflows/audit.yml.jinja
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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
5 changes: 5 additions & 0 deletions template/.github/workflows/ci.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Coverage runs on pull requests only. Pushes to main upload their
Expand Down
5 changes: 4 additions & 1 deletion template/docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions template/docs/users-guide.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions tests/helpers/ci_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
88 changes: 88 additions & 0 deletions tests/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"""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"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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)
Expand Down
Loading