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
8 changes: 4 additions & 4 deletions .github/workflows/plananvil-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
39 changes: 37 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,46 @@ 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: 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.11"
- name: Configure Git fixture identity
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ 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;
- 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

Expand Down
10 changes: 10 additions & 0 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 16 additions & 0 deletions tests/test_release_engineering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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'
Expand Down
20 changes: 20 additions & 0 deletions tools/release_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
Expand All @@ -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'
Expand All @@ -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:
Expand Down