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
35 changes: 35 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 26 additions & 15 deletions tests/helpers/ci_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import re
from typing import Any

from tests.helpers.generated_files import (
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
)
Expand Down
106 changes: 106 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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"""\
Expand Down
Loading