diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 19c6318..e982774 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -88,6 +88,41 @@ Docker availability, and runs `make test WITH_ACT=1`. Rust-enabled workflows pass `rust_extension/Cargo.toml` to the coverage action because the generated Python project root does not contain a Rust manifest. +### Workflow pins and Dependabot + +Dependabot owns the upgrade of GitHub Actions and reusable workflows, +including calls into `leynos/shared-actions`. Contract tests that assert a +caller's exact commit SHA create a lockstep dependency: every time Dependabot +opens a bump PR, the test fails until a human edits the pinned constant to +match. That defeats the purpose of automated dependency updates and turns a +routine bump into a manual chore. + +Contract tests may still verify the *shape* of a reusable-workflow caller. +They must not verify the specific SHA value. + +- Do assert the workflow references the correct reusable workflow path. +- Do assert the ref is pinned to a full 40-character commit SHA, not a + mutable branch such as `main` or `rolling`. +- Do assert the expected `on:` triggers, least-privilege `permissions:`, and + the inputs the caller relies on. +- Do not hard-code the current SHA value as an expected string. Match it with + a pattern instead. +- Do not fail a test purely because Dependabot bumped the pinned SHA. + +```python +import re + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + +def test_uses_pinned_full_sha(caller_step): + ref = caller_step["uses"].split("@")[-1] + assert SHA_RE.match(ref), f"expected a 40-hex commit SHA, got {ref!r}" +``` + +If a workflow's behaviour genuinely depends on a feature only present from a +particular commit onwards, express that as a comment or a changelog note, not +as a test assertion on the SHA string. + ## Shared Spelling Configuration [ADR-003](adr-003-shared-oxford-spelling-base.md) records the shared-base diff --git a/tests/helpers/ci_contracts.py b/tests/helpers/ci_contracts.py index 7821bec..edc925f 100644 --- a/tests/helpers/ci_contracts.py +++ b/tests/helpers/ci_contracts.py @@ -8,6 +8,7 @@ from __future__ import annotations +import re from typing import Any from tests.helpers.generated_files import ( @@ -16,6 +17,16 @@ require_sequence, ) +# Dependabot owns the shared-actions commit SHA; contract tests assert the +# reusable action path and that it is pinned to a full 40-hex commit SHA, but +# not which SHA. See docs/developers-guide.md, "Workflow pins and Dependabot". +_GENERATE_COVERAGE_USES_RE = re.compile( + r"^leynos/shared-actions/\.github/actions/generate-coverage@[0-9a-f]{40}$" +) +_UPLOAD_CODESCENE_COVERAGE_USES_RE = re.compile( + r"^leynos/shared-actions/\.github/actions/upload-codescene-coverage@[0-9a-f]{40}$" +) + def assert_ci_coverage_action_contract( *, ci_workflow: str, package_name: str, use_rust: bool @@ -68,11 +79,11 @@ def assert_ci_coverage_action_contract( ] assert len(coverage_steps) == 1, "expected one shared coverage action step" coverage_step = coverage_steps[0] - assert ( - coverage_step.get("uses") - == "leynos/shared-actions/.github/actions/generate-coverage" - "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd" - ), "expected CI to use the pinned shared coverage action" + coverage_uses = str(coverage_step.get("uses")) + assert _GENERATE_COVERAGE_USES_RE.match(coverage_uses), ( + "expected CI to use the shared coverage action pinned to a 40-hex " + f"commit SHA, got {coverage_uses!r}" + ) assert coverage_step.get("if") == "${{ github.event_name == 'pull_request' }}", ( "expected CI coverage generation to be guarded to pull requests so " "coverage-main.yml owns the push-to-main upload" @@ -162,11 +173,11 @@ def assert_coverage_main_workflow_contract( ] assert len(generate_steps) == 1, "expected one shared coverage generation step" generate_step = generate_steps[0] - assert ( - generate_step.get("uses") - == "leynos/shared-actions/.github/actions/generate-coverage" - "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd" - ), "expected coverage-main to use the pinned shared coverage action" + generate_uses = str(generate_step.get("uses")) + assert _GENERATE_COVERAGE_USES_RE.match(generate_uses), ( + "expected coverage-main to use the shared coverage action pinned to " + f"a 40-hex commit SHA, got {generate_uses!r}" + ) generate_inputs = require_mapping(generate_step, "with", "coverage generation step") assert generate_inputs.get("output-path") == "coverage.xml", ( "expected coverage-main to write cobertura coverage.xml" @@ -189,11 +200,11 @@ def assert_coverage_main_workflow_contract( ] assert len(upload_steps) == 1, "expected one guarded CodeScene upload step" upload_step = upload_steps[0] - assert ( - upload_step.get("uses") - == "leynos/shared-actions/.github/actions/upload-codescene-coverage" - "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd" - ), "expected coverage-main to use the pinned shared upload action" + upload_uses = str(upload_step.get("uses")) + assert _UPLOAD_CODESCENE_COVERAGE_USES_RE.match(upload_uses), ( + "expected coverage-main to use the shared upload action pinned to a " + f"40-hex commit SHA, got {upload_uses!r}" + ) assert upload_step.get("if") == "env.CS_ACCESS_TOKEN != ''", ( "expected the CodeScene upload to skip when the access token is absent" ) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 7f07d4e..ab45d7b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -395,6 +395,52 @@ def test_ci_coverage_action_contract_validates_rust_manifest_edge() -> None: ) +def test_ci_coverage_action_contract_accepts_any_pinned_sha_rejects_branch_ref() -> ( + None +): + """Validate the coverage action contract is SHA-value-agnostic but shape-strict. + + Parameters + ---------- + None + This test does not use pytest fixtures. + + Returns + ------- + None + The test passes when a workflow pinned to a different 40-hex commit + SHA is accepted (Dependabot owns the SHA value) and a workflow + pinned to a mutable branch ref is rejected. + """ + workflow = _ci_workflow( + persist_credentials="false", + coverage_inputs=" artefact-name-suffix: helper-pkg\n", + ) + + dependabot_bumped_workflow = workflow.replace( + "927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + "0123456789abcdef0123456789abcdef01234567", + ) + assert_ci_coverage_action_contract( + ci_workflow=dependabot_bumped_workflow, + package_name="helper_pkg", + use_rust=False, + ) + + branch_ref_workflow = workflow.replace( + "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", "@main" + ) + with pytest.raises( + AssertionError, + match="expected CI to use the shared coverage action pinned to a 40-hex", + ): + assert_ci_coverage_action_contract( + ci_workflow=branch_ref_workflow, + package_name="helper_pkg", + use_rust=False, + ) + + def test_parent_makefile_help_target_lists_available_targets() -> None: """Validate the parent repository ``help`` target output. @@ -603,6 +649,66 @@ def test_coverage_main_workflow_contract_requires_rust_setup() -> None: ) +def test_coverage_main_workflow_contract_accepts_any_pinned_sha_rejects_branch_ref() -> ( + None +): + """Validate the coverage-main contract is SHA-value-agnostic but shape-strict. + + Parameters + ---------- + None + This test does not use pytest fixtures. + + Returns + ------- + None + The test passes when a coverage-main workflow pinned to a different + 40-hex commit SHA is accepted (Dependabot owns the SHA value) and a + workflow pinned to a mutable branch ref is rejected. + """ + workflow = _coverage_main_workflow(guard="env.CS_ACCESS_TOKEN != ''") + + dependabot_bumped_workflow = workflow.replace( + "927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + "0123456789abcdef0123456789abcdef01234567", + ) + assert_coverage_main_workflow_contract( + coverage_main_workflow=dependabot_bumped_workflow, + package_name="helper_pkg", + use_rust=False, + ) + + branch_ref_workflow = workflow.replace( + "generate-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + "generate-coverage@main", + ) + with pytest.raises( + AssertionError, + match="expected coverage-main to use the shared coverage action pinned " + "to a 40-hex", + ): + assert_coverage_main_workflow_contract( + coverage_main_workflow=branch_ref_workflow, + package_name="helper_pkg", + use_rust=False, + ) + + upload_branch_ref_workflow = workflow.replace( + "upload-codescene-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + "upload-codescene-coverage@main", + ) + with pytest.raises( + AssertionError, + match="expected coverage-main to use the shared upload action pinned " + "to a 40-hex", + ): + assert_coverage_main_workflow_contract( + coverage_main_workflow=upload_branch_ref_workflow, + package_name="helper_pkg", + use_rust=False, + ) + + def _ci_workflow(*, persist_credentials: str, coverage_inputs: str) -> str: """Return a minimal generated CI workflow for coverage-contract tests.""" return f"""\