diff --git a/CHANGELOG.md b/CHANGELOG.md index 6650e26..ffecfeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `tests/test_docs_action_pin.py`, a regression contract asserting that every documented + root-action reference is a 40-character commit SHA and that all documented pins agree. + +### Fixed + +- Corrected the root-action pin in `llms.txt`, which still referenced the superseded `1a70edc` + commit after the audited pin moved to `671e8db`. +- Replaced the "replace it with an immutable release tag when one exists" guidance in `README.md`, + `SECURITY.md`, and `llms.txt`. The published `v0.1.0`, `v0.1.1`, and `v0.2.0` tags predate the + root `action.yml`, so following that guidance produced a workflow that cannot resolve the action. + ## [0.2.0] - 2026-08-08 ### Added diff --git a/README.md b/README.md index a4f5cc5..81777e5 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,9 @@ The action uploads `.pygate/` as an action-owned artifact, including hidden file ### Pinning policy -Use the root action at an immutable commit or an immutable release tag. The examples use the audited commit `671e8db37c2bd124d4b74653f8c81945d1592a8f`; replace it with an immutable release tag when one exists. Do not use a mutable branch reference for the root action. +Use the root action at an immutable commit. The examples use the audited commit `671e8db37c2bd124d4b74653f8c81945d1592a8f`. + +The published release tags `v0.1.0`, `v0.1.1`, and `v0.2.0` version the `pygate-ci` package on PyPI. They predate the root [`action.yml`](action.yml), so none of them resolves as a GitHub Actions reference: a workflow that pins the root action at `@v0.2.0` fails with a missing-action error. Pin the audited commit until a release tag is cut at or after the root action landed, then prefer that tag. Do not use a mutable branch reference for the root action. PyGate never grants merge authority. A workflow still decides whether a failed, timed-out, or escalated job blocks a pull request, and any comment or artifact should be treated as untrusted command output before security-sensitive rendering. diff --git a/SECURITY.md b/SECURITY.md index a551fc1..766a04b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,7 +47,7 @@ PyGate executes external tools (`ruff`, `pyright`, `pytest`) via subprocess. Sec - **Supply chain pinning**: All third-party actions in CI workflows and the composite action are pinned to SHA digests with version comments (e.g., `actions/checkout@ # v4`). This prevents compromised upstream tags from injecting malicious code. - **Permissions**: The composite action requires only `contents: read` by default. The optional PR comment feature requires `pull-requests: write`. No other permissions are requested. - **Full-mode dependencies**: The caller owns project and test dependencies. Before full mode, the caller must make `pytest` and `pytest-json-report` available in the Python version selected by the action; the action preflights them and does not install them. -- **Action pinning**: Use the root `hermes-labs-ai/quick-gate-python@671e8db37c2bd124d4b74653f8c81945d1592a8f` audited commit, or replace it with an immutable release tag when one exists. Do not use a mutable branch reference for the root action. +- **Action pinning**: Use the root `hermes-labs-ai/quick-gate-python@671e8db37c2bd124d4b74653f8c81945d1592a8f` audited commit. The `v0.1.0`, `v0.1.1`, and `v0.2.0` tags version the PyPI package and predate the root `action.yml`, so they do not resolve as an action reference. Do not use a mutable branch reference for the root action. - **Artifact trust**: Artifacts uploaded to `.pygate/` contain command output (stdout/stderr) from the target project. Downstream consumers should treat these as untrusted data and validate before rendering in security-sensitive contexts. ### Dependency Supply Chain diff --git a/llms.txt b/llms.txt index 9840196..e707dcc 100644 --- a/llms.txt +++ b/llms.txt @@ -13,10 +13,12 @@ Primary CLI: - `pygate repair --input ` Root GitHub Action quickstart (audited immutable commit): -- `hermes-labs-ai/quick-gate-python@1a70edc12bfd19e633983e0819b648bb2a5dda4e` +- `hermes-labs-ai/quick-gate-python@671e8db37c2bd124d4b74653f8c81945d1592a8f` - grant `contents: read` only for the blocking read-only baseline - use `mode: canary`, `fail-on-error: "true"`, and leave repair/comments disabled -- replace the commit with an immutable release tag when one exists; never use a mutable branch reference +- the `v0.1.0`, `v0.1.1`, and `v0.2.0` tags version the `pygate-ci` package and predate the root + `action.yml`, so they do not resolve as an action reference; pin the audited commit and never use a + mutable branch reference Root action inputs: - `mode`, `repair`, `max-attempts`, `python-version`, `post-comment` diff --git a/tests/test_docs_action_pin.py b/tests/test_docs_action_pin.py new file mode 100644 index 0000000..296bdbf --- /dev/null +++ b/tests/test_docs_action_pin.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Every place that tells a consumer how to reference the root action. +DOCUMENTED_PIN_SOURCES = ( + "README.md", + "SECURITY.md", + "llms.txt", + ".github/workflows/example-usage.yml", +) + +SELF_REFERENCE = re.compile(r"hermes-labs-ai/quick-gate-python@(\S+?)(?=[`\s]|$)") +COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$") + + +def _documented_refs() -> dict[str, list[str]]: + return {source: SELF_REFERENCE.findall((REPO_ROOT / source).read_text()) for source in DOCUMENTED_PIN_SOURCES} + + +def test_every_documented_source_pins_the_root_action() -> None: + for source, refs in _documented_refs().items(): + assert refs, f"{source} documents no root-action reference" + + +def test_documented_pins_are_immutable_commits() -> None: + for source, refs in _documented_refs().items(): + for ref in refs: + assert COMMIT_SHA.match(ref), ( + f"{source} references the root action at {ref!r}. The pinning policy requires a " + "40-character commit SHA: release tags predate the root action.yml and mutable " + "branch references are not allowed." + ) + + +def test_documented_pins_agree() -> None: + refs = _documented_refs() + distinct = {ref for source_refs in refs.values() for ref in source_refs} + assert len(distinct) == 1, "Documented root-action pins disagree, so at least one source is stale: " + "; ".join( + f"{source}={sorted(set(source_refs))}" for source, source_refs in refs.items() + )