diff --git a/.github/scripts/.version b/.github/scripts/.version index 3e7bcf0..e876398 100644 --- a/.github/scripts/.version +++ b/.github/scripts/.version @@ -1 +1 @@ -v1.0.4 +v1.0.6 diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index afe2149..2bed0c9 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -1,3 +1,6 @@ +# Release hygiene is detection-only here: repo rulesets block GITHUB_TOKEN from +# pushing to main or force-updating protected floating tags. Maintainers advance +# vX/vX.Y and bump .github/scripts/.version manually when the drift guard fails. name: Auto Release on: @@ -21,21 +24,33 @@ jobs: id: version shell: bash run: | - LATEST=$(git tag --sort=-v:refname --list 'v*' | head -1) + LATEST=$(git tag --sort=-v:refname --list 'v[0-9]*.[0-9]*.[0-9]*' | head -1) if [ -z "$LATEST" ]; then echo "tag=v1.0.0" >> "$GITHUB_OUTPUT" else - MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1) - MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2) - PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3) + if [[ ! "$LATEST" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::Latest release tag has an unsupported format: $LATEST" + exit 1 + fi + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" echo "tag=v${MAJOR}.${MINOR}.$((PATCH + 1))" >> "$GITHUB_OUTPUT" fi echo "Resolved next version: $(cat "$GITHUB_OUTPUT" | grep tag)" + - name: Create release tag + shell: bash + run: | + tag="${{ steps.version.outputs.tag }}" + git tag "$tag" HEAD + git push origin "refs/tags/${tag}" + - name: Create release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ steps.version.outputs.tag }}" \ + --verify-tag \ --title "${{ steps.version.outputs.tag }}" \ --generate-notes diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 9a82085..56880a0 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -53,6 +53,8 @@ jobs: ADR_SCHEMA_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || '' }} - name: Validate pin parity run: python tools/check_pin_parity.py + - name: Validate release drift + run: python tools/check_release_drift.py repo-hygiene: name: Org repo hygiene diff --git a/.gitignore b/.gitignore index 9e42eb3..639c2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ !/reference/** !/scripts/ !/scripts/** +!/tools/ +!/tools/** !/tests/ !/tests/** diff --git a/README.md b/README.md index b277eec..38fc235 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ Supported sync modes: 4. This repo dogfoods the same workflow directly (nightly schedule). 5. `template-ci.yml` runs the checks from `.github/scripts/`, validating the released artifacts. +Floating `vX` / `vX.Y` advancement and `.github/scripts/.version` stamping are manual maintainer tasks: an admin re-points the floating tag and bumps `.version` through a normal PR. The drift guard in `template-ci.yml` fails CI when the latest release, floating tag, or marker drifts, prompting that manual fix. Full automation is blocked until the shelved GitHub App path in [github-token-limitation.md](docs/reference/github-token-limitation.md) exists, because `GITHUB_TOKEN` cannot push to main or force-update protected tags. + Each repo controls its own update cadence — the template publishes releases, consumers pull when ready. No cross-repo credentials, no push permissions, no coupling. ## Git Hygiene Standard diff --git a/tests/test_check_release_drift.py b/tests/test_check_release_drift.py new file mode 100644 index 0000000..7642cb1 --- /dev/null +++ b/tests/test_check_release_drift.py @@ -0,0 +1,143 @@ +"""Tests for the release drift guard.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from tools import check_release_drift as release_drift + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 - controlled tests invoke local git + ["git", *args], # noqa: S607 + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def reusable_workflow(has_workflow_call: bool) -> str: + trigger = " workflow_call:\n" if has_workflow_call else " workflow_dispatch:\n" + return ( + "name: Reusable\n" + "\n" + "on:\n" + f"{trigger}" + "\n" + "jobs:\n" + " noop:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: echo ok\n" + ) + + +def write_release_files(repo: Path, tag: str, *, self_update_call: bool = True, python_qa_call: bool = True) -> None: + scripts_dir = repo / ".github" / "scripts" + workflows_dir = repo / ".github" / "workflows" + scripts_dir.mkdir(parents=True, exist_ok=True) + workflows_dir.mkdir(parents=True, exist_ok=True) + + (scripts_dir / ".version").write_text(f"{tag}\n", encoding="utf-8") + (workflows_dir / "self-update.yml").write_text(reusable_workflow(self_update_call), encoding="utf-8") + (workflows_dir / "python-qa.yml").write_text(reusable_workflow(python_qa_call), encoding="utf-8") + (repo / "README.md").write_text("# release fixture\n", encoding="utf-8") + + +def commit_all(repo: Path, message: str) -> None: + git(repo, "add", ".") + git(repo, "commit", "-m", message) + + +def create_release_repo( + tmp_path: Path, + *, + self_update_call: bool = True, + python_qa_call: bool = True, +) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-b", "main") + git(repo, "config", "user.name", "Release Test") + git(repo, "config", "user.email", "release-test@example.com") + git(repo, "config", "commit.gpgsign", "false") + git(repo, "config", "tag.gpgsign", "false") + + write_release_files(repo, "v1.0.5") + commit_all(repo, "release v1.0.5") + git(repo, "tag", "v1.0.5") + + write_release_files( + repo, + "v1.0.6", + self_update_call=self_update_call, + python_qa_call=python_qa_call, + ) + commit_all(repo, "release v1.0.6") + git(repo, "tag", "v1.0.6") + git(repo, "tag", "v1", "v1.0.6") + + return repo + + +def test_release_drift_guard_passes_when_version_and_floating_tag_are_current(tmp_path: Path) -> None: + repo = create_release_repo(tmp_path) + release_drift.ROOT = repo + + assert release_drift.collect_errors() == [] + + +def test_release_drift_guard_fails_when_version_marker_drifts(tmp_path: Path) -> None: + repo = create_release_repo(tmp_path) + release_drift.ROOT = repo + (repo / ".github" / "scripts" / ".version").write_text("v1.0.5\n", encoding="utf-8") + + errors = release_drift.collect_errors() + + assert any(".github/scripts/.version" in error for error in errors) + + +def test_release_drift_guard_fails_when_v1_points_at_an_old_release(tmp_path: Path) -> None: + repo = create_release_repo(tmp_path) + release_drift.ROOT = repo + git(repo, "tag", "--force", "v1", "v1.0.5") + + errors = release_drift.collect_errors() + + assert any("floating v1 resolves" in error for error in errors) + + +def test_release_drift_guard_fails_when_reusable_workflow_call_is_missing(tmp_path: Path) -> None: + repo = create_release_repo(tmp_path, self_update_call=False) + release_drift.ROOT = repo + + errors = release_drift.collect_errors() + + assert ".github/workflows/self-update.yml@v1 does not declare on: workflow_call" in errors + + +def test_declares_workflow_call_accepts_quoted_trigger_key() -> None: + workflow_text = 'name: Reusable\n\non:\n "workflow_call":\n\njobs:\n noop:\n runs-on: ubuntu-latest\n' + + assert release_drift.declares_workflow_call(workflow_text) + + +def test_declares_workflow_call_ignores_nested_input_named_workflow_call() -> None: + workflow_text = ( + "name: Manual\n" + "\n" + "on:\n" + " workflow_dispatch:\n" + " inputs:\n" + " workflow_call:\n" + " description: Not a reusable workflow trigger\n" + "\n" + "jobs:\n" + " noop:\n" + " runs-on: ubuntu-latest\n" + ) + + assert not release_drift.declares_workflow_call(workflow_text) diff --git a/tools/check_release_drift.py b/tools/check_release_drift.py new file mode 100644 index 0000000..ae821bc --- /dev/null +++ b/tools/check_release_drift.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Validate release version markers and floating reusable workflow tags.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") +ON_RE = re.compile(r"^on\s*:\s*(.*)$") +REUSABLE_WORKFLOWS = ( + ".github/workflows/self-update.yml", + ".github/workflows/python-qa.yml", +) + + +class GitError(RuntimeError): + """Raised when a required git lookup fails.""" + + +@dataclass(frozen=True, order=True) +class ReleaseTag: + major: int + minor: int + patch: int + name: str + + +def run_git(args: list[str]) -> str: + try: + return subprocess.check_output( # noqa: S603 - controlled git queries + ["git", *args], # noqa: S607 + cwd=ROOT, + encoding="utf-8", + stderr=subprocess.PIPE, + ).strip() + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() + detail = f": {stderr}" if stderr else "" + raise GitError(f"git {' '.join(args)} failed{detail}") from exc + + +def version_markers() -> tuple[Path, Path]: + return ( + ROOT / ".github" / "scripts" / ".version", + ROOT / "scripts" / ".version", + ) + + +def parse_release_tag(name: str) -> ReleaseTag | None: + match = RELEASE_TAG_RE.match(name) + if match is None: + return None + major, minor, patch = (int(part) for part in match.groups()) + return ReleaseTag(major=major, minor=minor, patch=patch, name=name) + + +def release_tags() -> list[ReleaseTag]: + output = run_git(["tag", "--list", "v*"]) + tags = [parsed for line in output.splitlines() if (parsed := parse_release_tag(line.strip())) is not None] + return sorted(tags) + + +def latest_release(tags: list[ReleaseTag]) -> ReleaseTag: + if not tags: + raise GitError("no vX.Y.Z release tags found") + return tags[-1] + + +def latest_release_for_major(tags: list[ReleaseTag], major: int) -> ReleaseTag: + matches = [tag for tag in tags if tag.major == major] + if not matches: + raise GitError(f"no v{major}.x release tags found") + return matches[-1] + + +def resolve_commit(ref: str) -> str: + return run_git(["rev-parse", "--verify", f"{ref}^{{commit}}"]) + + +def show_at_ref(ref: str, path: str) -> str: + return run_git(["show", f"{ref}:{path}"]) + + +def strip_yaml_comment(line: str) -> str: + quote = "" + result: list[str] = [] + for char in line: + if quote: + result.append(char) + if char == quote: + quote = "" + continue + if char in {"'", '"'}: + quote = char + result.append(char) + continue + if char == "#": + break + result.append(char) + return "".join(result).rstrip() + + +def mentions_workflow_call(value: str) -> bool: + return re.search(r"(? int: + return len(value) - len(value.lstrip(" ")) + + +def is_workflow_call_event(value: str) -> bool: + event = value.removeprefix("-").strip() + if event.startswith(("'", '"')): + quote = event[0] + if not event.startswith(f"{quote}workflow_call{quote}"): + return False + suffix = event[len("'workflow_call'") :].lstrip() + return suffix == "" or suffix.startswith(":") + return event == "workflow_call" or event.startswith("workflow_call:") + + +def declares_workflow_call(workflow_text: str) -> bool: + lines = workflow_text.splitlines() + + for index, line in enumerate(lines): + if line[:1].isspace(): + continue + + stripped = strip_yaml_comment(line).strip() + if not stripped: + continue + + match = ON_RE.match(stripped) + if match is None: + continue + + inline_value = match.group(1).strip() + if inline_value: + return mentions_workflow_call(inline_value) + + child_indent: int | None = None + for child in lines[index + 1 :]: + child_without_comment = strip_yaml_comment(child) + if not child_without_comment.strip(): + continue + if not child[:1].isspace(): + return False + + indent = leading_spaces(child_without_comment) + if child_indent is None: + child_indent = indent + if indent > child_indent: + continue + + child_value = child_without_comment.strip() + if is_workflow_call_event(child_value): + return True + if mentions_workflow_call(child_value) and child_value.startswith(("[", "{")): + return True + + return False + + return False + + +def collect_errors() -> list[str]: + errors: list[str] = [] + + try: + tags = release_tags() + latest = latest_release(tags) + latest_v1 = latest_release_for_major(tags, 1) + latest_v1_commit = resolve_commit(latest_v1.name) + except GitError as exc: + return [str(exc)] + + required_marker, optional_marker = version_markers() + if not required_marker.is_file(): + errors.append(".github/scripts/.version is missing") + else: + marker_value = required_marker.read_text(encoding="utf-8").strip() + if marker_value != latest.name: + errors.append(f".github/scripts/.version is {marker_value!r}, expected {latest.name!r}") + + if optional_marker.exists(): + marker_value = optional_marker.read_text(encoding="utf-8").strip() + if marker_value != latest.name: + errors.append(f"scripts/.version is {marker_value!r}, expected {latest.name!r}") + + try: + floating_v1_commit = resolve_commit("v1") + except GitError as exc: + errors.append(str(exc)) + else: + if floating_v1_commit != latest_v1_commit: + errors.append( + f"floating v1 resolves to {floating_v1_commit}, expected {latest_v1.name} at {latest_v1_commit}" + ) + + for workflow in REUSABLE_WORKFLOWS: + try: + workflow_text = show_at_ref("v1", workflow) + except GitError as exc: + errors.append(str(exc)) + continue + if not declares_workflow_call(workflow_text): + errors.append(f"{workflow}@v1 does not declare on: workflow_call") + + return errors + + +def main() -> int: + errors = collect_errors() + if errors: + sys.stderr.write("release-drift check failed:\n") + for error in errors: + sys.stderr.write(f" - {error}\n") + return 1 + + sys.stdout.write("release-drift check passed\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())