Skip to content
Open
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
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 on lines +17 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the job with timeout-minutes.

The job has no timeout-minutes, so a stuck cargo install, uv install, or pip-audit advisory-database fetch will occupy the runner up to GitHub's default six-hour ceiling before this weekly compensating control even reports a failure. Cap it.

⏱️ Proposed fix
   audit:
     runs-on: ubuntu-latest
+    timeout-minutes: 15
     {% if use_rust %}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
audit:
runs-on: ubuntu-latest
{% if use_rust %}
env:
CARGO_TERM_COLOR: always
{% endif %}
steps:
audit:
runs-on: ubuntu-latest
timeout-minutes: 15
{% if use_rust %}
env:
CARGO_TERM_COLOR: always
{% endif %}
steps:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@template/.github/workflows/audit.yml.jinja` around lines 17 - 23, The audit
job is missing a timeout, so add a `timeout-minutes` limit directly on the
`audit` job in the `audit` workflow template. Update the job definition near
`audit:` so it caps long-running stalls from `cargo install`, `uv`, or
`pip-audit`, keeping the existing `runs-on` and `env` structure intact.

- 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 @@ -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
Comment on lines 85 to 87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the guard condition explicitly in the CI contract test.

tests/helpers/ci_contracts.py currently only checks "make audit" in ci_workflow, per the supplied snippet. That assertion would still pass even if the new if: github.actor != 'dependabot[bot]' guard were dropped, mistyped, or inverted (e.g. == instead of !=), silently reintroducing the exact Dependabot auto-merge deadlock this PR fixes. Add a targeted assertion on the "Audit dependencies" step's if clause alongside the existing steps inspection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@template/.github/workflows/ci.yml.jinja` around lines 85 - 87, The CI
contract test currently only verifies that the workflow contains the "Audit
dependencies" step and the make audit command, so it would miss regressions in
the new Dependabot guard. Update the assertion logic in
tests/helpers/ci_contracts.py to explicitly inspect the "Audit dependencies"
step from ci_workflow and verify its if clause matches github.actor !=
'dependabot[bot]' alongside the existing steps checks. Make the check targeted
to the step’s condition so dropped, inverted, or mistyped guards are caught.


- name: Test and Measure Coverage
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 @@ -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"
Expand Down
65 changes: 65 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,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)
Expand Down
Loading