diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 8b99398..11db1d9 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -31,6 +31,10 @@ repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" if [[ "$bump_type" == "publish" ]]; then + if [[ -f .github/release-policy.json ]]; then + echo "ERROR: CI owns tagging for this repository; follow the release workflow run." >&2 + exit 1 + fi if [[ "$(git branch --show-current)" != "main" ]] || [[ -n "$(git status --porcelain)" ]]; then echo "ERROR: publish requires a clean main checkout." >&2 exit 1 diff --git a/.github/scripts/tests/release.sh b/.github/scripts/tests/release.sh index ea55d31..d004121 100644 --- a/.github/scripts/tests/release.sh +++ b/.github/scripts/tests/release.sh @@ -32,3 +32,9 @@ if bash "$script" publish; then echo 'Publishing an existing tag should fail' >&2 exit 1 fi +mkdir -p .github +printf '{}\n' > .github/release-policy.json +if bash "$script" publish; then + echo 'Manual publication with a CI policy should fail' >&2 + exit 1 +fi diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..69c5fee --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,94 @@ +# Shared implementation is pinned separately so no caller code runs with App secrets. +name: Validate and tag release + +on: + workflow_call: + inputs: + app-id: + description: Release GitHub App ID + type: string + required: true + secrets: + release-app-private-key: + required: true + outputs: + tag: + description: Release tag, empty for a push without release intent + value: ${{ jobs.validate.outputs.tag }} + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + release: ${{ steps.intent.outputs.release }} + tag: ${{ steps.intent.outputs.tag }} + commit: ${{ steps.intent.outputs.commit }} + steps: + - name: Require a main push + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + DELETED: ${{ github.event.deleted }} + run: | + test "$EVENT" = push + test "$REF" = refs/heads/main + test "$DELETED" = false + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + path: caller + - uses: actions/checkout@v5 + with: + repository: BehindTheMusicTree/github-workflows + ref: f304059fd7e9a9d0743a9e6a76ae11ea6e12f121 + persist-credentials: false + path: foundation + - name: Validate the exact push + id: intent + working-directory: caller + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + POLICY: .github/release-policy.json + run: | + python3 ../foundation/release/validate-release.py "$BEFORE" "$AFTER" "$POLICY" > "$RUNNER_TEMP/release-intent.json" + jq -r '"release=\(.release)", "tag=\(.tag // "")", "commit=\(.commit // "")"' "$RUNNER_TEMP/release-intent.json" >> "$GITHUB_OUTPUT" + + tag: + needs: validate + if: needs.validate.outputs.release == 'true' + runs-on: ubuntu-latest + # Each event is independent: a global concurrency group can drop pending bumps. + steps: + - uses: actions/checkout@v5 + with: + repository: BehindTheMusicTree/github-workflows + ref: f304059fd7e9a9d0743a9e6a76ae11ea6e12f121 + persist-credentials: false + - name: Require App configuration + env: + APP_ID: ${{ inputs.app-id }} + APP_PRIVATE_KEY: ${{ secrets.release-app-private-key }} + run: | + test -n "$APP_ID" || { echo 'Release App ID missing' >&2; exit 1; } + test -n "$APP_PRIVATE_KEY" || { echo 'Release App private key missing' >&2; exit 1; } + - name: Create repository-scoped App token + id: app + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.release-app-private-key }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + - name: Create or verify immutable annotated tag + env: + RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }} + TAG: ${{ needs.validate.outputs.tag }} + COMMIT: ${{ needs.validate.outputs.commit }} + run: python3 release/create-tag.py "$TAG" "$COMMIT" diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 68fe6e3..c6d0b1c 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -48,3 +48,15 @@ jobs: - uses: actions/checkout@v5 - name: Verify prepare and publish run: bash .github/scripts/tests/release.sh + + release-foundation-tests: + name: release foundation tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - name: Verify validation, collisions and retries without remote writes + run: python3 -m unittest discover -s release -p 'test_*.py' + - name: Lint release shell scripts + run: shellcheck .github/scripts/release.sh .github/scripts/tests/release.sh diff --git a/.gitignore b/.gitignore index 49ff6e9..3cc80e7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .local/ .github/pr-descriptions/ .claude/worktrees/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5afae..96c0d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat ## [Unreleased] +### Added + +- Add a reusable release validator and atomic App-based annotated tagging workflow, with explicit version contracts, migration safeguards and retry tests. + ## [5.0.1] - 2026-11-02 diff --git a/README.md b/README.md index b7ce8bf..d798e44 100644 --- a/README.md +++ b/README.md @@ -392,3 +392,25 @@ See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes, including ve ## License [Specify your license here] + +## Release validation and tagging + +The reusable [release-tag workflow](.github/workflows/release-tag.yml) validates a push to `main` and creates an annotated tag on that event's exact commit. It does not build packages, create GitHub Releases, or deploy. Existing tag-triggered publishers keep their permissions, OIDC identities and workflow filenames. + +An adopter commits `.github/release-policy.json` using the [schema](release/release-policy.schema.json) and [example policy](examples/release-policy.json). The contract declares one canonical version, all mirrors, a tag prefix, a changelog and expected publication outputs. JSON and TOML sources use dot-separated keys; text files contain a version. Versions must be stable SemVer without leading zeroes. The validator enforces repository-relative paths and rejects duplicate sources. The changelog must contain exactly one `## [VERSION]` section with content beyond headings and HTML comments. + +Use the [caller example](examples/release-tag.yml), replacing its placeholder with a full **merged and reviewed commit SHA**. Configure `RELEASE_APP_ID` and the explicitly named `RELEASE_APP_PRIVATE_KEY` secret. Install the App on the adopting repository with Contents write permission; select only adopting repositories for organization secrets. Do not pass registry credentials or `secrets: inherit`. + +Before enabling a caller, configure separate tag rulesets: creation permits the release App; update/deletion protection has no App bypass. The App must have no bypass of `main` protection. PR checks must cover the version and publisher changes before merge. If a repository also requires checks after merge, add those jobs in the caller's push workflow and make the reusable-workflow job depend on their successful completion with `needs`. Those checks must operate on `github.sha`, including reruns after a later push; a check of the latest branch tip is insufficient. + +Activation and changes to the contract must land in a separate push from a version bump. Activation validates the current version, mirrors and changelog but never tags it, including an existing version with no tag. A subsequent push with no bump also produces no tag. For an established contract, a bump must increase the version and leave the policy unchanged; all mirrors and its changelog section must match. The version comparison uses the push's before/after commits, never the current branch tip. Force pushes and newly created branch events fail closed. + +Prepare version and changelog in a PR using the project's own tools. In this repository, `.github/scripts/release.sh major|minor|patch` prepares a commit on a feature branch. Its legacy `publish` command remains available to repositories without the CI policy; once `.github/release-policy.json` exists, manual publication is refused. This foundation PR does not activate automatic tagging for github-workflows itself. + +Validation runs without App secrets and checks ancestry against fetched `origin/main`. A separate runner checks out only this repository's shared implementation at an immutable SHA, checks configuration, then requests an App token scoped to the caller repository and Contents write. The App-token action revokes it in its post step. No caller script or build runs in that job. Maintainers changing the Python implementation must commit it first and update **both** helper checkout SHAs in the reusable workflow to that reviewed snapshot. The outer workflow is independently pinned by each caller. + +Each run creates an annotated tag object, then atomically creates its ref. An existing lightweight or annotated tag that dereferences to the same commit is a successful retry; a tag on another commit fails. Concurrent creation is accepted only after resolving the winner to the same SHA. API failures propagate. There is no global concurrency group that can discard a pending bump. A failed API response after ref creation can be retried with the same push event; never move a tag or bump again to recover. + +`outputs` records the publisher contract; this tagger does not assert that those artifacts are published. For a partial publication, rerun the existing tag publisher after checking each required artifact. In a multi-package project the publisher must skip an already published identical version and distinguish registry errors from an absent package. GitHub Release creation must have one owner and wait for every required package. Do not replace a missing publisher run with an arbitrary workflow dispatch. + +Run `python3 -m unittest discover -s release -p 'test_*.py'`, `bash .github/scripts/tests/release.sh`, `shellcheck .github/scripts/release.sh .github/scripts/tests/release.sh`, and `actionlint`. Tests simulate tagging and API errors without credentials or remote writes. Python 3.11+ is required. Repository-specific publisher recovery and a live App run are adoption checks, beyond the local foundation tests. diff --git a/examples/release-policy.json b/examples/release-policy.json new file mode 100644 index 0000000..b3acdc2 --- /dev/null +++ b/examples/release-policy.json @@ -0,0 +1,8 @@ +{ + "release_branch": "main", + "version": { "path": "VERSION", "format": "text" }, + "mirrors": [], + "tag_prefix": "v", + "changelog": "CHANGELOG.md", + "outputs": { "github_release": true, "packages": [] } +} diff --git a/examples/release-tag.yml b/examples/release-tag.yml new file mode 100644 index 0000000..bbff5d7 --- /dev/null +++ b/examples/release-tag.yml @@ -0,0 +1,15 @@ +# Place at .github/workflows/release-tag.yml in an adopting repository. +name: Release tag +on: + push: + branches: [main] +permissions: + contents: read +jobs: + release: + # Replace with the full merged, reviewed github-workflows commit SHA. + uses: BehindTheMusicTree/github-workflows/.github/workflows/release-tag.yml@REPLACE_WITH_MERGED_COMMIT_SHA + with: + app-id: ${{ vars.RELEASE_APP_ID }} + secrets: + release-app-private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/release/create-tag.py b/release/create-tag.py new file mode 100644 index 0000000..291e457 --- /dev/null +++ b/release/create-tag.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Create an immutable annotated release tag; never build or publish artifacts.""" + +import json +import os +import re +import sys +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +class APIError(ValueError): + def __init__(self, status): + super().__init__(f"GitHub API request failed (HTTP {status})") + self.status = status + + +class GitHub: + def __init__(self, repository, token, api_url): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("invalid repository") + if not token: + raise ValueError("release App token missing") + if not api_url.startswith("https://"): + raise ValueError("GitHub API URL must use HTTPS") + self.base = f"{api_url.rstrip('/')}/repos/{repository}" + self.token = token + + def request(self, method, path, data=None): + request = Request( + self.base + path, + data=json.dumps(data).encode() if data is not None else None, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + method=method, + ) + try: + with urlopen(request, timeout=30) as response: + return json.load(response) + except HTTPError as exc: + raise APIError(exc.code) from None + + +def existing_commit(api, tag): + try: + obj = api.request("GET", "/git/ref/tags/" + quote(tag, safe=""))["object"] + except APIError as exc: + if exc.status == 404: + return None + raise + seen = set() + while obj["type"] == "tag": + sha = obj["sha"] + if sha in seen or len(seen) >= 20 or not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ValueError("invalid annotated tag chain") + seen.add(sha) + obj = api.request("GET", "/git/tags/" + sha)["object"] + if obj["type"] != "commit" or not re.fullmatch(r"[0-9a-f]{40}", obj["sha"]): + raise ValueError("release tag must resolve to a commit") + return obj["sha"] + + +def create_tag(api, tag, commit): + if not re.fullmatch(r"[A-Za-z0-9_-]+(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", tag): + raise ValueError("invalid stable release tag") + if not re.fullmatch(r"[0-9a-f]{40}", commit) or commit == "0" * 40: + raise ValueError("invalid commit SHA") + current = existing_commit(api, tag) + if current is not None: + if current != commit: + raise ValueError("release tag collision: existing tag targets another commit") + return "existing" + annotated = api.request("POST", "/git/tags", { + "tag": tag, "message": f"Release {tag}", "object": commit, "type": "commit", + }) + sha = annotated["sha"] + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ValueError("invalid tag object SHA returned by GitHub") + try: + api.request("POST", "/git/refs", {"ref": "refs/tags/" + tag, "sha": sha}) + except APIError as exc: + # Another run may win the atomic ref creation. Only the same commit is a retry. + if exc.status != 422: + raise + current = existing_commit(api, tag) + if current is None: + raise exc + if current != commit: + raise ValueError("release tag collision: concurrent tag targets another commit") from None + return "existing" + if existing_commit(api, tag) != commit: + raise ValueError("created tag did not resolve to the expected commit") + return "created" + + +if __name__ == "__main__": + try: + if len(sys.argv) != 3: + raise ValueError("usage: create-tag.py TAG COMMIT") + api = GitHub(os.environ["GITHUB_REPOSITORY"], os.environ["RELEASE_APP_TOKEN"], os.environ["GITHUB_API_URL"]) + sys.stdout.write(create_tag(api, *sys.argv[1:]) + "\n") + except (ValueError, KeyError, TypeError, OSError) as exc: + sys.stderr.write(f"release tag creation failed: {exc}\n") + sys.exit(1) diff --git a/release/release-policy.schema.json b/release/release-policy.schema.json new file mode 100644 index 0000000..3a2c286 --- /dev/null +++ b/release/release-policy.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/BehindTheMusicTree/github-workflows/release-policy.schema.json", + "title": "Release policy", + "description": "Explicit version and publication contract for a repository.", + "type": "object", + "additionalProperties": false, + "required": [ + "release_branch", + "version", + "mirrors", + "tag_prefix", + "changelog", + "outputs" + ], + "properties": { + "release_branch": { + "const": "main" + }, + "version": { + "$ref": "#/$defs/versionSource" + }, + "mirrors": { + "type": "array", + "items": { + "$ref": "#/$defs/versionSource" + }, + "uniqueItems": true + }, + "tag_prefix": { + "type": "string", + "minLength": 1, + "not": { + "pattern": "[^A-Za-z0-9_-]" + } + }, + "changelog": { + "$ref": "#/$defs/repositoryPath" + }, + "outputs": { + "type": "object", + "additionalProperties": false, + "required": [ + "github_release", + "packages" + ], + "properties": { + "github_release": { + "type": "boolean" + }, + "packages": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + } + }, + "$defs": { + "versionSource": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "format" + ], + "properties": { + "path": { + "$ref": "#/$defs/repositoryPath" + }, + "format": { + "enum": [ + "text", + "json", + "toml" + ] + }, + "key": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*$" + } + }, + "allOf": [ + { + "if": { + "properties": { + "format": { + "const": "text" + } + } + }, + "then": { + "not": { + "required": [ + "key" + ] + } + }, + "else": { + "required": [ + "key" + ] + } + } + ] + }, + "repositoryPath": { + "type": "string", + "minLength": 1, + "pattern": "^(?![-/])(?!.*(?:^|/)[.]{1,2}(?:/|$))(?!.*//)(?!.*[:\\\\\\r\\n])[\\s\\S]*[^/]$" + } + } +} diff --git a/release/test_create_tag.py b/release/test_create_tag.py new file mode 100644 index 0000000..06d8f3a --- /dev/null +++ b/release/test_create_tag.py @@ -0,0 +1,137 @@ +"""Exercise API failures and interleavings without creating remote tags.""" + +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch +from urllib.error import HTTPError, URLError + +spec = importlib.util.spec_from_file_location("tagger", Path(__file__).with_name("create-tag.py")) +tagger = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tagger) +COMMIT = "a" * 40 +OTHER = "b" * 40 +OBJECT = "c" * 40 + + +class FakeGitHub: + def __init__(self): + self.refs = {} + self.tags = {} + self.calls = [] + self.failure = None + self.race = None + + def request(self, method, path, data=None): + self.calls.append((method, path, data)) + if self.failure: + raise tagger.APIError(self.failure) + if path.startswith("/git/ref/tags/"): + name = path.removeprefix("/git/ref/tags/") + if name not in self.refs: + raise tagger.APIError(404) + return {"object": self.refs[name]} + if path.startswith("/git/tags/"): + return {"object": self.tags[path.removeprefix("/git/tags/")]} + if path == "/git/tags": + sha = f"{len(self.tags) + 1:040x}" + self.tags[sha] = {"type": "commit", "sha": data["object"]} + return {"sha": sha} + if path == "/git/refs": + name = data["ref"].removeprefix("refs/tags/") + if self.race: + self.refs[name] = {"type": "commit", "sha": self.race} + raise tagger.APIError(422) + self.refs[name] = {"type": "tag", "sha": data["sha"]} + return {} + raise AssertionError((method, path, data)) + + +class TagCreationTest(unittest.TestCase): + def test_annotated_creation_and_retry(self): + api = FakeGitHub() + self.assertEqual(tagger.create_tag(api, "v1.1.0", COMMIT), "created") + self.assertEqual(api.refs["v1.1.0"]["type"], "tag") + self.assertEqual(tagger.create_tag(api, "v1.1.0", COMMIT), "existing") + self.assertEqual(sum(method == "POST" for method, _, _ in api.calls), 2) + + def test_lightweight_retry_and_collision(self): + api = FakeGitHub() + api.refs["v1.1.0"] = {"type": "commit", "sha": COMMIT} + self.assertEqual(tagger.create_tag(api, "v1.1.0", COMMIT), "existing") + with self.assertRaisesRegex(ValueError, "collision"): + tagger.create_tag(api, "v1.1.0", OTHER) + self.assertFalse(any(method == "POST" for method, _, _ in api.calls)) + + def test_atomic_collision_same_and_different_sha(self): + for target in (COMMIT, OTHER): + with self.subTest(target=target): + api = FakeGitHub() + api.race = target + if target == COMMIT: + self.assertEqual(tagger.create_tag(api, "v1.1.0", COMMIT), "existing") + else: + with self.assertRaisesRegex(ValueError, "collision"): + tagger.create_tag(api, "v1.1.0", COMMIT) + + def test_two_close_bumps_keep_event_commits(self): + api = FakeGitHub() + tagger.create_tag(api, "v1.1.0", COMMIT) + tagger.create_tag(api, "v1.2.0", OTHER) + targets = [data["object"] for method, path, data in api.calls if method == "POST" and path == "/git/tags"] + self.assertEqual(targets, [COMMIT, OTHER]) + self.assertEqual(tagger.existing_commit(api, "v1.1.0"), COMMIT) + self.assertEqual(tagger.existing_commit(api, "v1.2.0"), OTHER) + + def test_nested_tag_and_cycle(self): + api = FakeGitHub() + api.refs["v1.1.0"] = {"type": "tag", "sha": OBJECT} + api.tags[OBJECT] = {"type": "tag", "sha": OTHER} + api.tags[OTHER] = {"type": "commit", "sha": COMMIT} + self.assertEqual(tagger.create_tag(api, "v1.1.0", COMMIT), "existing") + api.tags[OTHER] = {"type": "tag", "sha": OBJECT} + with self.assertRaisesRegex(ValueError, "chain"): + tagger.create_tag(api, "v1.1.0", COMMIT) + + def test_api_errors_do_not_mean_absence(self): + for status in (401, 403, 429, 500): + with self.subTest(status=status): + api = FakeGitHub() + api.failure = status + with self.assertRaises(tagger.APIError): + tagger.create_tag(api, "v1.1.0", COMMIT) + self.assertEqual(len(api.calls), 1) + + def test_ref_validation_failure_is_not_success(self): + api = FakeGitHub() + original = api.request + + def request(method, path, data=None): + if path == "/git/refs": + raise tagger.APIError(422) + return original(method, path, data) + + api.request = request + with self.assertRaises(tagger.APIError): + tagger.create_tag(api, "v1.1.0", COMMIT) + + def test_missing_token_and_invalid_inputs(self): + with self.assertRaisesRegex(ValueError, "token missing"): + tagger.GitHub("owner/repo", "", "https://api.github.com") + for tag, commit in (("v1.2.3\n", COMMIT), ("v1.0.0", "main"), ("v1.0.0", "0" * 40)): + with self.subTest(tag=tag, commit=commit), self.assertRaises(ValueError): + tagger.create_tag(FakeGitHub(), tag, commit) + + def test_transport_errors_propagate_and_auth_is_not_logged(self): + api = tagger.GitHub("owner/repo", "private-token", "https://api.github.com") + with patch.object(tagger, "urlopen", side_effect=URLError("network unavailable")): + with self.assertRaises(URLError): + api.request("GET", "/git/ref/tags/v1.0.0") + with patch.object(tagger, "urlopen", side_effect=HTTPError("url", 403, "private-token", {}, None)): + with self.assertRaises(tagger.APIError) as caught: + api.request("GET", "/git/ref/tags/v1.0.0") + self.assertNotIn("private-token", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/test_validate_release.py b/release/test_validate_release.py new file mode 100644 index 0000000..b998597 --- /dev/null +++ b/release/test_validate_release.py @@ -0,0 +1,207 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('validator', Path(__file__).with_name('validate-release.py')) +validator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(validator) + + +class ReleaseValidationTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.git('init', '-q', '-b', 'main') + self.git('config', 'user.name', 'Test') + self.git('config', 'user.email', 'test@example.com') + self.policy = {'release_branch': 'main', 'version': {'path': 'VERSION', 'format': 'text'}, + 'mirrors': [{'path': 'package.json', 'format': 'json', 'key': 'version'}], + 'tag_prefix': 'v', 'changelog': 'CHANGELOG.md', + 'outputs': {'github_release': True, 'packages': []}} + self.write('policy.json', json.dumps(self.policy)) + self.write('VERSION', '1.0.0\n') + self.write('package.json', '{"version":"1.0.0"}') + self.write('CHANGELOG.md', '## [1.0.0] - 2026-01-01\n\n- Initial.\n') + self.before = self.commit() + + def tearDown(self): + self.tmp.cleanup() + + def git(self, *args): + return subprocess.check_output(['git', *args], cwd=self.root, text=True).strip() + + def write(self, name, text): + (self.root / name).write_text(text) + + def commit(self): + self.git('add', '.') + self.git('commit', '-qm', 'test', '--allow-empty') + sha = self.git('rev-parse', 'HEAD') + self.git('update-ref', 'refs/remotes/origin/main', sha) + return sha + + def check(self, after, policy_path='policy.json'): + import os + previous = os.getcwd() + try: + os.chdir(self.root) + return validator.validate(self.before, after, policy_path) + finally: + os.chdir(previous) + + def test_no_bump_is_noop_even_without_tag(self): + self.assertEqual(self.check(self.commit()), {'release': False}) + + def test_bump_and_mirror(self): + self.write('VERSION', '1.1.0\n') + self.write('package.json', '{"version":"1.1.0"}') + self.write('CHANGELOG.md', '## [1.1.0] - 2026-01-02\n\n- Feature.\n') + after = self.commit() + self.assertEqual(self.check(after)['commit'], after) + self.assertEqual(self.check(after)['tag'], 'v1.1.0') + + def test_mismatch_and_downgrade_fail(self): + self.write('VERSION', '1.1.0\n') + with self.assertRaisesRegex(ValueError, 'mirror mismatch'): + self.check(self.commit()) + self.write('VERSION', '0.9.0\n') + self.write('package.json', '{"version":"0.9.0"}') + self.write('CHANGELOG.md', '## [0.9.0]\n- Downgrade.\n') + with self.assertRaisesRegex(ValueError, 'must increase'): + self.check(self.commit()) + + def test_missing_changelog_and_invalid_version_fail(self): + self.write('VERSION', '1.1.0\n') + self.write('package.json', '{"version":"1.1.0"}') + with self.assertRaisesRegex(ValueError, 'changelog'): + self.check(self.commit()) + self.write('VERSION', '01.1.0\n') + with self.assertRaisesRegex(ValueError, 'SemVer'): + self.check(self.commit()) + + def bump(self, version='1.1.0'): + self.write('VERSION', version + '\n') + self.write('package.json', json.dumps({'version': version})) + self.write('CHANGELOG.md', f'## [{version}]\n\n- Change.\n') + + def test_activation_never_releases(self): + self.git('rm', 'policy.json') + self.before = self.commit() + self.write('policy.json', json.dumps(self.policy)) + self.assertEqual(self.check(self.commit()), {'release': False}) + self.bump() + self.assertEqual(self.check(self.commit()), {'release': False}) + + def test_policy_change_requires_separate_push(self): + self.policy['tag_prefix'] = 'release-' + self.write('policy.json', json.dumps(self.policy)) + self.assertEqual(self.check(self.commit()), {'release': False}) + self.bump() + with self.assertRaisesRegex(ValueError, 'separate pushes'): + self.check(self.commit()) + + def test_full_validation_without_bump(self): + cases = [ + ('missing changelog', lambda: self.policy.update(changelog='missing.md')), + ('unsafe path', lambda: self.policy.update(changelog='../CHANGELOG.md')), + ('invalid mirror', lambda: self.policy.update(mirrors=[{'format': 'unknown', 'path': 'VERSION'}])), + ('invalid key', lambda: self.policy['mirrors'][0].update(key='a..b')), + ('duplicate source', lambda: self.policy.update(mirrors=[self.policy['version']])), + ('invalid outputs', lambda: self.policy['outputs'].update(packages=['a', 'a'])), + ('unknown field', lambda: self.policy.update(unknown=True)), + ('invalid prefix', lambda: self.policy.update(tag_prefix='v/')), + ] + original = json.dumps(self.policy) + for label, change in cases: + with self.subTest(label=label): + self.policy = json.loads(original) + change() + self.write('policy.json', json.dumps(self.policy)) + with self.assertRaises(ValueError): + self.check(self.commit()) + + def test_no_bump_mirror_mismatch(self): + self.write('package.json', '{"version":"1.0.1"}') + with self.assertRaisesRegex(ValueError, 'mirror mismatch'): + self.check(self.commit()) + + def test_empty_and_duplicate_changelog(self): + for text in ('## [1.0.0]\n', '## [1.0.0]\n### Added\n\n', + '## [1.0.0]\n- First.\n## [1.0.0]\n- Duplicate.\n'): + with self.subTest(text=text): + self.write('CHANGELOG.md', text) + with self.assertRaisesRegex(ValueError, 'changelog'): + self.check(self.commit()) + + def test_nonstable_versions(self): + for version in ('1.1.0-rc.1', 'v1.1.0', '1.1.0+build', '1.01.0', '0.0', ' '): + with self.subTest(version=version): + self.write('VERSION', version) + with self.assertRaisesRegex(ValueError, 'SemVer'): + self.check(self.commit()) + + def test_invalid_shas_and_off_main(self): + after = self.commit() + for before in ('main', '0' * 40, after + '\n'): + with self.subTest(before=before), self.assertRaises(ValueError): + validator.validate(before, after, 'policy.json') + self.git('update-ref', 'refs/remotes/origin/main', self.before) + with self.assertRaises(ValueError): + self.check(after) + + def test_event_sha_can_precede_main_tip(self): + self.bump() + event = self.commit() + self.bump('1.2.0') + later = self.commit() + self.assertEqual(self.check(event)['commit'], event) + self.before = event + self.assertEqual(self.check(later)['commit'], later) + + def test_toml_source_and_dummy_root_manifest(self): + self.write('project.toml', '[project]\nversion = "1.0.0"\n') + self.policy['version'] = {'path': 'project.toml', 'format': 'toml', 'key': 'project.version'} + self.policy['mirrors'] = [] + self.write('policy.json', json.dumps(self.policy)) + self.write('package.json', '{"version":"0.0.0"}') + self.before = self.commit() + self.write('project.toml', '[project]\nversion = "1.1.0"\n') + self.write('CHANGELOG.md', '## [1.1.0]\n- Feature.\n') + self.assertEqual(self.check(self.commit())['version'], '1.1.0') + + def test_two_coordinated_packages(self): + self.policy['mirrors'].append({'path': 'second.json', 'format': 'json', 'key': 'version'}) + self.write('second.json', '{"version":"1.0.0"}') + self.write('policy.json', json.dumps(self.policy)) + self.before = self.commit() + self.bump() + with self.assertRaisesRegex(ValueError, 'mirror mismatch'): + self.check(self.commit()) + self.write('second.json', '{"version":"1.1.0"}') + self.assertTrue(self.check(self.commit())['release']) + + def test_missing_policy_fails(self): + self.git('rm', 'policy.json') + with self.assertRaises(ValueError): + self.check(self.commit()) + + def test_nested_policy_detected_on_previous_commit(self): + (self.root / '.github').mkdir() + self.git('mv', 'policy.json', '.github/release-policy.json') + self.before = self.commit() + self.bump() + self.assertTrue(self.check(self.commit(), '.github/release-policy.json')['release']) + + def test_unrelated_before_fails(self): + self.git('checkout', '-q', '--orphan', 'unrelated') + self.write('unrelated.txt', 'different root') + after = self.commit() + with self.assertRaises(ValueError): + self.check(after) + + +if __name__ == '__main__': + unittest.main() diff --git a/release/validate-release.py b/release/validate-release.py new file mode 100644 index 0000000..6d70794 --- /dev/null +++ b/release/validate-release.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Validate a push's release intent without credentials or side effects.""" + +import json +import re +import subprocess +import sys +import tomllib +from pathlib import PurePosixPath + +SEMVER = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") +SHA = re.compile(r"[0-9a-f]{40}\Z") + + +def git(*args): + result = subprocess.run(["git", *args], capture_output=True, text=True, check=False) + if result.returncode: + raise ValueError(f"git {' '.join(args)} failed: {result.stderr.strip()}") + return result.stdout + + +def repository_path(path): + if ( + not isinstance(path, str) + or not path + or path.startswith("-") + or PurePosixPath(path).is_absolute() + or any(part in ("", ".", "..") for part in path.split("/")) + or any(char in path for char in ":\\\r\n\0") + ): + raise ValueError("invalid repository path") + return path + + +def read_file(commit, path): + return git("show", f"{commit}:{repository_path(path)}") + + +def source_config(source): + if not isinstance(source, dict) or set(source) not in ({"path", "format"}, {"path", "format", "key"}): + raise ValueError("invalid version source") + repository_path(source["path"]) + fmt = source["format"] + if fmt == "text" and "key" not in source: + return + if fmt not in ("json", "toml") or not isinstance(source.get("key"), str): + raise ValueError("invalid version format or key") + if not all(re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in source["key"].split(".")): + raise ValueError("invalid version key") + + +def version_at(commit, source): + source_config(source) + data = read_file(commit, source["path"]) + fmt = source["format"] + if fmt == "text": + version = data.strip() + else: + obj = json.loads(data) if fmt == "json" else tomllib.loads(data) + for part in source["key"].split("."): + if not isinstance(obj, dict) or part not in obj: + raise ValueError("version key missing") + obj = obj[part] + version = obj + if not isinstance(version, str) or not SEMVER.fullmatch(version): + raise ValueError("version must be stable SemVer without leading zeroes") + return version + + +def policy_at(commit, path): + policy = json.loads(read_file(commit, path)) + fields = {"release_branch", "version", "mirrors", "tag_prefix", "changelog", "outputs"} + if not isinstance(policy, dict) or set(policy) != fields or policy["release_branch"] != "main": + raise ValueError("invalid release policy") + source_config(policy["version"]) + prefix = policy["tag_prefix"] + if not isinstance(prefix, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", prefix): + raise ValueError("invalid tag prefix") + mirrors = policy["mirrors"] + if not isinstance(mirrors, list): + raise ValueError("invalid mirrors") + sources = [policy["version"]] + for mirror in mirrors: + source_config(mirror) + if mirror in sources: + raise ValueError("duplicate version source") + sources.append(mirror) + repository_path(policy["changelog"]) + outputs = policy["outputs"] + if ( + not isinstance(outputs, dict) + or set(outputs) != {"github_release", "packages"} + or type(outputs["github_release"]) is not bool + or not isinstance(outputs["packages"], list) + or any(not isinstance(p, str) or not p.strip() or "\n" in p for p in outputs["packages"]) + or len(set(outputs["packages"])) != len(outputs["packages"]) + ): + raise ValueError("invalid outputs") + return policy + + +def check_current(commit, policy): + version = version_at(commit, policy["version"]) + for mirror in policy["mirrors"]: + if version_at(commit, mirror) != version: + raise ValueError("version mirror mismatch") + changelog = read_file(commit, policy["changelog"]) + sections = list(re.finditer(r"^## \[" + re.escape(version) + r"\](?:[^\S\n][^\n]*)?$", changelog, re.MULTILINE)) + if len(sections) != 1: + raise ValueError("exactly one versioned changelog section required") + body = re.split(r"^## ", changelog[sections[0].end():], maxsplit=1, flags=re.MULTILINE)[0] + body = re.sub(r"", "", body, flags=re.DOTALL) + if not any(line.strip() and not line.lstrip().startswith("#") for line in body.splitlines()): + raise ValueError("versioned changelog section is empty") + git("check-ref-format", f"refs/tags/{policy['tag_prefix']}{version}") + return version + + +def validate(before, after, policy_path): + for sha in (before, after): + if not isinstance(sha, str) or not SHA.fullmatch(sha) or sha == "0" * 40: + raise ValueError("push requires two exact nonzero commit SHAs") + if git("cat-file", "-t", sha).strip() != "commit": + raise ValueError("push SHA must identify a commit") + git("merge-base", "--is-ancestor", before, after) + git("merge-base", "--is-ancestor", after, "refs/remotes/origin/main") + policy = policy_at(after, policy_path) + new = check_current(after, policy) + # Activation never publishes the pre-existing version, even without a tag. + paths = git("ls-tree", "--name-only", before, "--", repository_path(policy_path)).splitlines() + if policy_path not in paths: + return {"release": False} + previous = policy_at(before, policy_path) + old = check_current(before, previous) + if old == new: + return {"release": False} + if policy != previous: + raise ValueError("release policy changes and version bumps require separate pushes") + if tuple(map(int, new.split("."))) <= tuple(map(int, old.split("."))): + raise ValueError("version must increase") + return {"release": True, "version": new, "tag": policy["tag_prefix"] + new, "commit": after} + + +if __name__ == "__main__": + try: + if len(sys.argv) != 4: + raise ValueError("usage: validate-release.py BEFORE AFTER POLICY_PATH") + sys.stdout.write(json.dumps(validate(*sys.argv[1:])) + "\n") + except (ValueError, KeyError, TypeError, OSError) as exc: + sys.stderr.write(f"release validation failed: {exc}\n") + sys.exit(1)