From 6372e6377ee832d72e37b78d1b63ea26227b563c Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:12:41 +0200 Subject: [PATCH 1/8] Update CI actions to v7 --- .github/workflows/plananvil-tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/plananvil-tests.yml b/.github/workflows/plananvil-tests.yml index 4916bf5..b69f26c 100644 --- a/.github/workflows/plananvil-tests.yml +++ b/.github/workflows/plananvil-tests.yml @@ -33,9 +33,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Check out repository - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} - name: Configure Git fixture identity @@ -57,9 +57,9 @@ jobs: timeout-minutes: 15 steps: - name: Check out repository - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" - name: Configure Git fixture identity From 45b2127a4329377d5d4de1cf93a0bbbf8a223942 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:12:53 +0200 Subject: [PATCH 2/8] Update release actions to v7 --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 863b652..fb669a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,11 +18,11 @@ jobs: timeout-minutes: 20 steps: - name: Check out tagged source - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" - name: Configure Git fixture identity From 69a508a0fbfc21dd0b3ed155370b3d488450892a Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:13:08 +0200 Subject: [PATCH 3/8] Document Actions v7 hardening --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf6924..f064845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to PlanAnvil are documented here. ## [Unreleased] -No queued changes. +### Changed + +- update pinned `actions/checkout` and `actions/setup-python` workflow SHAs to the current v7 releases while retaining immutable action pinning and Node 24 compatibility. ## [0.2.0] - 2026-08-28 From 19bf02dcb0df995127de8c9c13f5db9829bf1342 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:15:22 +0200 Subject: [PATCH 4/8] Harden production tag provenance --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb669a4..fd0c6ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,41 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + - name: Verify signed annotated tag and main ancestry + env: + GH_TOKEN: ${{ github.token }} + run: | + python - <<'PY' + import json + import os + import subprocess + import sys + + repository = os.environ["GITHUB_REPOSITORY"] + tag = os.environ["GITHUB_REF_NAME"] + ref = json.loads(subprocess.check_output([ + "gh", "api", f"repos/{repository}/git/ref/tags/{tag}" + ], text=True)) + obj = ref.get("object", {}) + if obj.get("type") != "tag": + raise SystemExit("release tag must be an annotated signed tag; lightweight tags are rejected") + tag_obj = json.loads(subprocess.check_output([ + "gh", "api", f"repos/{repository}/git/tags/{obj.get('sha')}" + ], text=True)) + verification = tag_obj.get("verification") or {} + if verification.get("verified") is not True: + reason = verification.get("reason", "unknown") + raise SystemExit(f"release tag signature is not verified by GitHub: {reason}") + if (tag_obj.get("object") or {}).get("type") != "commit": + raise SystemExit("release tag must point directly to a commit") + print("GitHub verified release tag signature.") + PY + git fetch origin main + TAGGED_COMMIT="$(git rev-parse "${GITHUB_REF_NAME}^{}")" + git merge-base --is-ancestor "$TAGGED_COMMIT" origin/main || { + echo "Release tag target is not reachable from origin/main." >&2 + exit 1 + } - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: From 5054966f79e164be260757c7edac7305f41225ff Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:15:42 +0200 Subject: [PATCH 5/8] Require a clean production release tree --- tools/release_check.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/release_check.py b/tools/release_check.py index b027d5c..c3d5735 100644 --- a/tools/release_check.py +++ b/tools/release_check.py @@ -3,6 +3,7 @@ import argparse import json import re +import subprocess import sys import tempfile from pathlib import Path @@ -13,6 +14,22 @@ SEMVER = re.compile(r'^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$') +def _git_clean_blocker(root: Path) -> str | None: + result = subprocess.run( + ['git', '-C', str(root), 'status', '--porcelain', '--untracked-files=all'], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or f'exit {result.returncode}' + return f'cannot verify clean release tree: {detail}' + if result.stdout.strip(): + return 'release tree is dirty; commit or remove all tracked and untracked changes before production release' + return None + + def release_blockers(root: Path, *, require_reproduced: bool = True, tag: str | None = None) -> list[str]: blockers: list[str] = [] version_path = root / 'VERSION' @@ -39,6 +56,9 @@ def release_blockers(root: Path, *, require_reproduced: bool = True, tag: str | blockers.append(f'tag {tag!r} does not match VERSION v{version}') if require_reproduced: + clean_blocker = _git_clean_blocker(root) + if clean_blocker is not None: + blockers.append(clean_blocker) blockers.extend(validate_all(root)) else: try: From 859434309ef8aa77cefb2365affc5b0be9e38253 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:15:55 +0200 Subject: [PATCH 6/8] Test production clean-tree release gate --- tests/test_release_engineering.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_release_engineering.py b/tests/test_release_engineering.py index afb3a9b..8720f39 100644 --- a/tests/test_release_engineering.py +++ b/tests/test_release_engineering.py @@ -4,6 +4,7 @@ import sys import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / 'tools')) @@ -27,6 +28,21 @@ def test_candidate_release_metadata_passes_but_live_gate_remains_closed(self) -> strict = release_check.release_blockers(ROOT, require_reproduced=True) self.assertTrue(any('required capability' in item for item in strict), strict) + def test_production_release_rejects_dirty_tree(self) -> None: + completed = type('Completed', (), {'returncode': 0, 'stdout': ' M README.md\n', 'stderr': ''})() + with patch.object(release_check.subprocess, 'run', return_value=completed): + blockers = release_check.release_blockers(ROOT, require_reproduced=True) + self.assertIn( + 'release tree is dirty; commit or remove all tracked and untracked changes before production release', + blockers, + ) + + def test_production_release_fails_closed_if_git_cleanliness_cannot_be_verified(self) -> None: + completed = type('Completed', (), {'returncode': 128, 'stdout': '', 'stderr': 'not a git repository'})() + with patch.object(release_check.subprocess, 'run', return_value=completed): + blockers = release_check.release_blockers(ROOT, require_reproduced=True) + self.assertIn('cannot verify clean release tree: not a git repository', blockers) + def test_release_archive_is_deterministic(self) -> None: with tempfile.TemporaryDirectory() as tmp: first = Path(tmp) / 'first' From c779a57c94b064db2c89f03c710c2e41de10d4db Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:16:15 +0200 Subject: [PATCH 7/8] Document release provenance hardening --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f064845..6297919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ All notable changes to PlanAnvil are documented here. ### Changed -- update pinned `actions/checkout` and `actions/setup-python` workflow SHAs to the current v7 releases while retaining immutable action pinning and Node 24 compatibility. +- update pinned `actions/checkout` and `actions/setup-python` workflow SHAs to the current v7 releases while retaining immutable action pinning and Node 24 compatibility; +- require production releases to use a GitHub-verified signed annotated tag whose target is reachable from `main`; +- fail the production release gate closed when the release worktree is dirty or Git cleanliness cannot be verified. ## [0.2.0] - 2026-08-28 From 380a51a096666005b56dfb4e16cec5c2cbf37105 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:17:19 +0200 Subject: [PATCH 8/8] Document release provenance gates --- docs/RELEASE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 434b5c6..8cda994 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -38,6 +38,16 @@ git push origin v0.2.0 `.github/workflows/release.yml` reruns the deterministic gates, requires all release-gating capabilities to be `REPRODUCED`, builds a deterministic ZIP + checksum, and creates the GitHub Release. It cannot publish while capability evidence remains blocked. +Before any release artifact is built, the tag workflow also fails closed unless: + +- the pushed tag is an annotated tag object rather than a lightweight tag; +- GitHub reports the tag signature as cryptographically verified; +- the tag points directly to a commit; +- the tagged commit is reachable from `origin/main`; +- the checked-out release tree is clean, including untracked files. + +The production `release_check.py` enforces clean-tree state in addition to version, changelog, distribution manifest, release-file and live capability gates. Candidate mode intentionally skips the live-evidence and clean-production-tree requirements so ordinary PR CI remains usable. + ## Repository administration prerequisite Before production release, protect `main` as tracked in issue #6: PR-only changes, required CI, up-to-date branch, conversation resolution, and no force push/delete.