From e75bd1f5daf9c18fac2f4e0b64348cc11024518b Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 24 Apr 2026 14:55:36 -0700 Subject: [PATCH] new release process --- .github/workflows/release-gate.yml | 49 ++ .github/workflows/release.yml | 51 +- .github/workflows/release/README.md | 86 +++ .github/workflows/release/release.py | 376 ++++++++++++ .github/workflows/release/test_release.py | 543 ++++++++++++++++++ .../workflows/validate-release-request.yml | 28 + .gitignore | 5 + .releases/.gitkeep | 0 .releases/README.md | 65 +++ 9 files changed, 1195 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release-gate.yml create mode 100644 .github/workflows/release/README.md create mode 100644 .github/workflows/release/release.py create mode 100644 .github/workflows/release/test_release.py create mode 100644 .github/workflows/validate-release-request.yml create mode 100644 .releases/.gitkeep create mode 100644 .releases/README.md diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000..13ca8e60 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,49 @@ +name: Release Gate +on: + pull_request: + types: [closed] + branches: [main] + paths: ['.releases/**'] + +jobs: + create-release-tag: + name: Create tag from release request + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event.pull_request.merged == true + permissions: + contents: write + steps: + - name: Fail if App credentials are not configured + run: | + if [ -z "${{ secrets.APP_ID }}" ] || [ -z "${{ secrets.APP_PRIVATE_KEY }}" ]; then + echo "❌ APP_ID and APP_PRIVATE_KEY must be configured." + echo "For fork testing, install a personal GitHub App on the fork," + echo "create a private key, and add both as repository secrets." + exit 1 + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python deps + run: pip install pyyaml + + - name: Create tag from release request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: python .github/workflows/release/release.py gate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be779b85..02515357 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,30 @@ permissions: packages: write jobs: + # Determines whether this tag should update :latest on GHCR. + # Runs once; both Docker jobs consume its output. + determine-latest: + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + value: ${{ steps.is_latest.outputs.value }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch tags + run: git fetch --tags + + - name: Check is-latest + id: is_latest + run: | + VALUE=$(python .github/workflows/release/release.py is-latest "${{ github.ref_name }}") + echo "value=$VALUE" >> $GITHUB_OUTPUT + # Builds the x64 and arm64 binaries for Linux, for all 3 crates, via the Docker builder build-binaries-linux: + timeout-minutes: 60 strategy: matrix: target: @@ -44,6 +66,9 @@ jobs: run: | echo "Releasing commit: $(git rev-parse HEAD)" + - name: Set lowercase owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -63,8 +88,8 @@ jobs: context: . push: false platforms: linux/amd64,linux/arm64 - cache-from: type=registry,ref=ghcr.io/commit-boost/buildcache:${{ matrix.target-crate}} - cache-to: type=registry,ref=ghcr.io/commit-boost/buildcache:${{ matrix.target-crate }},mode=max + cache-from: type=registry,ref=ghcr.io/${{ env.OWNER }}/buildcache:${{ matrix.target-crate}} + cache-to: type=registry,ref=ghcr.io/${{ env.OWNER }}/buildcache:${{ matrix.target-crate }},mode=max file: provisioning/build.Dockerfile outputs: type=local,dest=build build-args: | @@ -85,6 +110,7 @@ jobs: # Builds the arm64 binaries for Darwin, for all 3 crates, natively build-binaries-darwin: + timeout-minutes: 60 strategy: matrix: target: @@ -160,8 +186,9 @@ jobs: # Builds the PBS Docker image build-and-push-pbs-docker: - needs: [build-binaries-linux] + needs: [build-binaries-linux, determine-latest] runs-on: ubuntu-latest + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v4 @@ -184,6 +211,9 @@ jobs: tar -xzf ./artifacts/commit-boost-pbs-${{ github.ref_name }}-linux_arm64/commit-boost-pbs-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin mv ./artifacts/bin/commit-boost-pbs ./artifacts/bin/linux_arm64/commit-boost-pbs + - name: Set lowercase owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -206,14 +236,15 @@ jobs: build-args: | BINARIES_PATH=./artifacts/bin tags: | - ghcr.io/commit-boost/pbs:${{ github.ref_name }} - ${{ !contains(github.ref_name, 'rc') && 'ghcr.io/commit-boost/pbs:latest' || '' }} + ghcr.io/${{ env.OWNER }}/pbs:${{ github.ref_name }} + ${{ needs.determine-latest.outputs.value == 'true' && format('ghcr.io/{0}/pbs:latest', env.OWNER) || '' }} file: provisioning/pbs.Dockerfile # Builds the Signer Docker image build-and-push-signer-docker: - needs: [build-binaries-linux] + needs: [build-binaries-linux, determine-latest] runs-on: ubuntu-latest + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v4 @@ -236,6 +267,9 @@ jobs: tar -xzf ./artifacts/commit-boost-signer-${{ github.ref_name }}-linux_arm64/commit-boost-signer-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin mv ./artifacts/bin/commit-boost-signer ./artifacts/bin/linux_arm64/commit-boost-signer + - name: Set lowercase owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -258,8 +292,8 @@ jobs: build-args: | BINARIES_PATH=./artifacts/bin tags: | - ghcr.io/commit-boost/signer:${{ github.ref_name }} - ${{ !contains(github.ref_name, 'rc') && 'ghcr.io/commit-boost/signer:latest' || '' }} + ghcr.io/${{ env.OWNER }}/signer:${{ github.ref_name }} + ${{ needs.determine-latest.outputs.value == 'true' && format('ghcr.io/{0}/signer:latest', env.OWNER) || '' }} file: provisioning/signer.Dockerfile # Creates a draft release on GitHub with the binaries @@ -270,6 +304,7 @@ jobs: - build-and-push-pbs-docker - build-and-push-signer-docker runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Download artifacts uses: actions/download-artifact@v4 diff --git a/.github/workflows/release/README.md b/.github/workflows/release/README.md new file mode 100644 index 00000000..4944bef6 --- /dev/null +++ b/.github/workflows/release/README.md @@ -0,0 +1,86 @@ +# Release Management Scripts + +Python CLI that backs the three release workflows: + +- `.github/workflows/validate-release-request.yml` → `release.py validate-pr` +- `.github/workflows/release-gate.yml` → `release.py gate` +- `.github/workflows/release.yml` (the `determine-latest` job) → `release.py is-latest ` + +All release-request validation, tag creation, and `:latest` gating logic lives here. The workflows are thin orchestration — they set env vars and invoke a subcommand. + +## Requirements + +- Python 3.10+ (tested on 3.12) +- `pyyaml` +- `gh` CLI (authenticated via `GH_TOKEN` env for API calls) +- `git` (for repo-local queries) + +``` +pip install pyyaml pytest +``` + +## Running the tests + +``` +pytest .github/workflows/release/test_release.py -v +``` + +All 61 tests use mocked `subprocess.run` boundaries and a tmp-repo fixture — they don't touch the network. + +## Subcommands + +Quick reference — each subcommand exits 0 on success, non-zero on failure, and prints `❌` / `✅` messages to stdout. + +| Command | Purpose | +| --- | --- | +| `validate-filename ` | Strict semver regex check (no leading zeros, `-rcN` suffix allowed) | +| `validate-yaml ` | Parse and schema-check a release-request YAML | +| `find-added --base --head ` | List `.releases/*.yml` files added in a diff range | +| `check-modifications --base --head ` | Reject modifications/deletions of existing YAMLs | +| `check-commit-exists ` | Verify commit exists via GitHub API | +| `check-tag-free ` | Verify tag doesn't already exist | +| `check-signatures ` | Confirm all commits from nearest tag ancestor to the release commit are signed | +| `create-tag ` | Create signed tag via GitHub API (`POST /git/tags` + `POST /git/refs`) | +| `is-latest ` | Print `true`/`false` — is this the highest non-RC semver? | +| `validate-pr` | End-to-end validator used by the PR workflow | +| `gate` | End-to-end gate used post-merge to create the tag | + +## Running locally + +Most subcommands need env vars: + +``` +export REPO=commit-boost/commit-boost-client +export GH_TOKEN=$(gh auth token) + +# Quick lint of a YAML +python .github/workflows/release/release.py validate-yaml .releases/v1.2.3.yml + +# Is v1.2.3 the latest? +python .github/workflows/release/release.py is-latest v1.2.3 + +# Simulate the PR validator end-to-end +export BASE_SHA=$(git merge-base origin/main HEAD) +export HEAD_SHA=HEAD +python .github/workflows/release/release.py validate-pr +``` + +## Layout + +``` +.github/workflows/release/ +├── release.py # The CLI +├── test_release.py # pytest suite (unit + tmp-repo integration) +└── README.md # This file +``` + +Located alongside the workflow files that call it. This follows the same convention as the ethereum-package Kurtosis repo, which keeps its workflow-supporting Python under `.github/workflows/`. + +YAML test cases are inlined in `test_release.py` as string constants and written to `tmp_path` at test time — no standalone fixtures directory. + +## Design notes + +- **Single-file script, no `__init__.py`.** Keeps invocation simple and avoids packaging ceremony for a 400-line tool. +- **`run_git()` is the git boundary**; `gh_api()` is the GitHub API boundary. Both are patchable in tests. `_run()` handles raw subprocess mechanics. +- **Error messages match the workflow conventions.** `❌` for failures, `✅` for success. The old inline shell used the same prefixes. +- **Strict semver regex lives in release.py** as `SEMVER_RE` — import it from tests so the regex is authoritative in one place. diff --git a/.github/workflows/release/release.py b/.github/workflows/release/release.py new file mode 100644 index 00000000..708db930 --- /dev/null +++ b/.github/workflows/release/release.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Release management CLI for Commit-Boost. + +Single-file argparse CLI. PyYAML + stdlib only. Shells out to ``git`` and +``gh`` via ``subprocess.run``. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import yaml + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _env(name: str) -> str: + """Read *name* from the environment; exit 1 with a clear message if missing.""" + val = os.environ.get(name) + if not val: + print(f"❌ Required environment variable ${name} is not set.") + sys.exit(1) + return val + + +class GhApiError(Exception): + """Raised when a ``gh api`` call fails non-zero.""" + + +def gh_api(method: str, path: str, **fields) -> dict | list: + """Thin wrapper over ``gh api``. Returns parsed JSON.""" + token = _env("GH_TOKEN") + repo = _env("REPO") + full_path = f"/repos/{repo}{path}" + argv = ["gh", "api", "--method", method, full_path] + for k, v in fields.items(): + argv.extend(["-f", f"{k}={v}"]) + if method.upper() == "GET": + argv.append("--paginate") + env = os.environ.copy() + env["GH_TOKEN"] = token + result = subprocess.run(argv, capture_output=True, text=True, env=env) + if result.returncode != 0: + print(result.stderr, file=sys.stderr, end="") + raise GhApiError( + f"gh api {method} {full_path} failed (exit {result.returncode})" + ) + if not result.stdout.strip(): + return {} + return json.loads(result.stdout) + + +def _run(*args: str) -> str: + """Wrapper over ``subprocess.run`` with check, capture_output, text.""" + result = subprocess.run(list(args), capture_output=True, text=True, check=True) + return result.stdout + + +# Public alias so tests can patch `release.run_git` at the boundary. +# Also lets callers shell out to git explicitly when intent needs to be clear. +def run_git(*args: str) -> str: + return _run("git", *args) + + +def _git_diff(base: str, head: str, diff_filter: str) -> list[str]: + """Return list of .releases/*.yml files from git diff with *diff_filter*.""" + try: + out = run_git( + "diff", "--name-only", f"--diff-filter={diff_filter}", + f"{base}..{head}", "--", ".releases/*.yml", + ) + except subprocess.CalledProcessError: + return [] + return [l for l in out.strip().split("\n") if l] + + +# ── core validation helpers ───────────────────────────────────────────────── + +SEMVER_RE = re.compile( + r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc[1-9][0-9]*)?$" +) + + +def _semver_key(tag: str) -> tuple: + """Return a comparable key for a semver tag (e.g. ``v1.2.3``).""" + m = re.match(r"^v(\d+)\.(\d+)\.(\d+)(?:-rc(\d+))?$", tag) + if not m: + return (0, 0, 0, 0) + return ( + int(m.group(1)), int(m.group(2)), int(m.group(3)), + int(m.group(4)) if m.group(4) else float("inf"), + ) + + +def validate_yaml_file(path: str) -> tuple[str, str]: + """Parse and validate a release-request YAML. Returns (commit_sha, tag).""" + try: + text = Path(path).read_text() + except FileNotFoundError: + print(f"❌ File not found: {path}") + sys.exit(1) + + try: + data = yaml.safe_load(text) + except yaml.YAMLError as e: + print(f"❌ YAML parse error: {e}") + sys.exit(1) + + if not isinstance(data, dict): + print("❌ YAML must be a mapping (dict)") + sys.exit(1) + + missing = {"commit", "reason"} - data.keys() + if missing: + print(f"❌ Missing required fields: {missing}") + sys.exit(1) + + commit = data["commit"] + if ( + not isinstance(commit, str) or len(commit) != 40 + or not all(c in "0123456789abcdef" for c in commit) + ): + print("❌ commit must be a 40-character lowercase hex SHA") + sys.exit(1) + + reason = data["reason"] + if not isinstance(reason, str) or not reason.strip(): + print("❌ reason must be a non-empty string") + sys.exit(1) + + tag = Path(path).stem + return commit, tag + + +# ── subcommands ────────────────────────────────────────────────────────────── + +def cmd_validate_filename(args: argparse.Namespace) -> None: + if SEMVER_RE.match(args.basename): + print(f"✅ Valid release filename: {args.basename}") + sys.exit(0) + print( + f"❌ Filename '{args.basename}' is not a valid release tag.\n" + "Expected: v.. or v..-rc, " + "no leading zeros" + ) + sys.exit(1) + + +def cmd_validate_yaml(args: argparse.Namespace) -> None: + commit, tag = validate_yaml_file(args.path) + print(f"tag={tag}") + print(f"commit={commit}") + print(f"✅ YAML validation passed for {Path(args.path).name}") + sys.exit(0) + + +def cmd_find_added(args: argparse.Namespace) -> None: + files = _git_diff(args.base, args.head, "A") + for f in files: + print(f) + print(f"count={len(files)}", file=sys.stderr) + sys.exit(0) + + +def cmd_check_modifications(args: argparse.Namespace) -> None: + files = _git_diff(args.base, args.head, "MD") + if files: + print("❌ Existing release YAMLs cannot be modified or deleted:") + for f in files: + print(f) + sys.exit(1) + print("✅ No modifications or deletions detected") + sys.exit(0) + + +def cmd_check_commit_exists(args: argparse.Namespace) -> None: + try: + gh_api("GET", f"/commits/{args.sha}") + print(f"✅ Commit {args.sha} exists") + sys.exit(0) + except GhApiError: + print(f"❌ Commit {args.sha} does not exist in this repository") + sys.exit(1) + + +def cmd_check_tag_free(args: argparse.Namespace) -> None: + try: + gh_api("GET", f"/git/refs/tags/{args.tag}") + print(f"❌ Tag {args.tag} already exists. Pick a different version.") + sys.exit(1) + except GhApiError: + print(f"✅ Tag {args.tag} is free") + sys.exit(0) + + +def cmd_check_signatures(args: argparse.Namespace) -> None: + commit = args.commit + try: + prev_tag = run_git("describe", "--tags", "--abbrev=0", f"{commit}^").strip() + except subprocess.CalledProcessError: + print("⚠️ No prior tag ancestor found; skipping ancestor signature check") + sys.exit(0) + + print(f"Comparing signatures from {prev_tag} (ancestor of {commit}) to {commit}...") + try: + data = gh_api("GET", f"/compare/{prev_tag}...{commit}") + except GhApiError: + print("❌ Failed to compare revisions") + sys.exit(1) + + commits = data if isinstance(data, list) else data.get("commits", []) + unsigned = [ + c["sha"] + for c in commits + if not c.get("commit", {}).get("verification", {}).get("verified", False) + ] + if unsigned: + print(f"❌ Unsigned commits between {prev_tag} and {commit}:") + for sha in unsigned: + print(sha) + print("Every commit in a release must be signed.") + sys.exit(1) + + print(f"✅ All commits between {prev_tag} and {commit} are signed.") + sys.exit(0) + + +def cmd_create_tag(args: argparse.Namespace) -> None: + tag_obj = gh_api( + "POST", "/git/tags", + tag=args.tag, message=args.tag, + object=args.commit, type="commit", + ) + tag_sha = tag_obj.get("sha") if isinstance(tag_obj, dict) else None + if not tag_sha: + print("❌ Failed to create tag object") + sys.exit(1) + + gh_api( + "POST", "/git/refs", + ref=f"refs/tags/{args.tag}", sha=tag_sha, + ) + print(f"✅ Tag {args.tag} created at {args.commit} (signed by GitHub via App identity)") + sys.exit(0) + + +def cmd_is_latest(args: argparse.Namespace) -> None: + tag = args.tag + try: + all_tags = run_git("tag", "--list", "v*").strip().split("\n") + except subprocess.CalledProcessError: + print("true") + sys.exit(0) + non_rc = [t for t in all_tags if t and not re.search(r"-rc\d+$", t)] + if not non_rc: + print("true") + sys.exit(0) + highest = sorted(non_rc, key=_semver_key)[-1] + print("true" if highest == tag else "false") + sys.exit(0) + + +def cmd_validate_pr(args: argparse.Namespace) -> None: + base = _env("BASE_SHA") + head = _env("HEAD_SHA") + + added = _git_diff(base, head, "A") + mods = _git_diff(base, head, "MD") + + if mods: + print("❌ Existing release YAMLs cannot be modified or deleted:") + for m in mods: + print(m) + sys.exit(1) + + if len(added) == 0: + print("added_count=0") + print("No release changes in this PR; validation trivially passes.") + sys.exit(0) + + if len(added) > 1: + print("❌ Only one release YAML may be added per PR.") + for a in added: + print(a) + sys.exit(1) + + filepath = added[0] + basename = Path(filepath).stem + + cmd_validate_filename(argparse.Namespace(basename=basename)) + commit, _ = validate_yaml_file(filepath) + cmd_check_commit_exists(argparse.Namespace(sha=commit)) + cmd_check_tag_free(argparse.Namespace(tag=basename)) + cmd_check_signatures(argparse.Namespace(commit=commit)) + + print(f"added_count=1") + print(f"tag={basename}") + print(f"commit={commit}") + print(f"✅ Release request for {basename} validated.") + + +def cmd_gate(args: argparse.Namespace) -> None: + base = _env("BASE_SHA") + merge_sha = _env("MERGE_SHA") + + added = _git_diff(base, merge_sha, "A") + if len(added) != 1: + print(f"Expected exactly 1 added release YAML, got {len(added)}. Skipping.") + sys.exit(0) + + filepath = added[0] + commit, tag = validate_yaml_file(filepath) + cmd_create_tag(argparse.Namespace(tag=tag, commit=commit)) + + +# ── main ───────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description="Commit-Boost release management") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("validate-filename", help="Validate a release filename against strict semver") + p.add_argument("basename") + p.set_defaults(func=cmd_validate_filename) + + p = sub.add_parser("validate-yaml", help="Parse and validate a release-request YAML file") + p.add_argument("path") + p.set_defaults(func=cmd_validate_yaml) + + p = sub.add_parser("find-added", help="List release YAMLs added between two refs") + p.add_argument("--base", required=True) + p.add_argument("--head", required=True) + p.set_defaults(func=cmd_find_added) + + p = sub.add_parser("check-modifications", help="Reject modifications/deletions of release YAMLs") + p.add_argument("--base", required=True) + p.add_argument("--head", required=True) + p.set_defaults(func=cmd_check_modifications) + + p = sub.add_parser("check-commit-exists", help="Verify a commit SHA exists in the repo") + p.add_argument("sha") + p.set_defaults(func=cmd_check_commit_exists) + + p = sub.add_parser("check-tag-free", help="Verify a tag does not already exist") + p.add_argument("tag") + p.set_defaults(func=cmd_check_tag_free) + + p = sub.add_parser("check-signatures", help="Check that all commits to a ref are signed") + p.add_argument("commit") + p.set_defaults(func=cmd_check_signatures) + + p = sub.add_parser("create-tag", help="Create an annotated tag via GitHub API") + p.add_argument("tag") + p.add_argument("commit") + p.set_defaults(func=cmd_create_tag) + + p = sub.add_parser("is-latest", help="Check if a tag is the highest non-RC semver") + p.add_argument("tag") + p.set_defaults(func=cmd_is_latest) + + p = sub.add_parser("validate-pr", help="End-to-end PR validator (reads env)") + p.set_defaults(func=cmd_validate_pr) + + p = sub.add_parser("gate", help="End-to-end gate after merge (reads env)") + p.set_defaults(func=cmd_gate) + + parsed = parser.parse_args() + parsed.func(parsed) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release/test_release.py b/.github/workflows/release/test_release.py new file mode 100644 index 00000000..f6cd766b --- /dev/null +++ b/.github/workflows/release/test_release.py @@ -0,0 +1,543 @@ +"""Tests for release.py — pure-logic and mocked-network coverage.""" + +import json +import os +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch, call + +import pytest + +from release import ( + SEMVER_RE, + _semver_key, + cmd_validate_filename, + cmd_validate_yaml, + cmd_find_added, + cmd_check_modifications, + cmd_is_latest, + cmd_check_signatures, + cmd_check_commit_exists, + cmd_check_tag_free, + GhApiError, +) + +HERE = Path(__file__).parent + + +def _write_yaml(tmp_path: Path, name: str, content: str) -> str: + """Write a YAML fixture into tmp_path and return the absolute path.""" + p = tmp_path / name + p.write_text(content) + return str(p) + + +# Inline YAML fixtures — kept next to the tests that use them for readability. +GOOD_YAML = """\ +commit: abcdef1234567890abcdef1234567890abcdef12 +reason: "Emergency pagination fix" +""" + +BAD_SCHEMA_YAML = """\ +commit: abcdef1234567890abcdef1234567890abcdef12 +""" # missing reason + +BAD_SHA_LENGTH_YAML = """\ +commit: abcdef1234567890abcdef1234567890abcdef123 +reason: "Too long SHA" +""" + +BAD_SHA_CHARS_YAML = """\ +commit: xbcdef1234567890abcdef1234567890abcdef12 +reason: "Invalid hex char x" +""" + +EMPTY_REASON_YAML = """\ +commit: abcdef1234567890abcdef1234567890abcdef12 +reason: "" +""" + +NOT_A_MAPPING_YAML = """\ +- item1 +- item2 +""" + + +# ── validate-filename ──────────────────────────────────────────────────────── + +class TestValidateFilename: + def test_passes_full_release(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v1.2.3")) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "✅" in out + + def test_passes_rc_release(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v1.2.3-rc1")) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "✅" in out + + def test_passes_v0_0_1(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v0.0.1")) + assert exc.value.code == 0 + + def test_passes_v10_20_30(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v10.20.30")) + assert exc.value.code == 0 + + def test_fails_no_v_prefix(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="1.2.3")) + assert exc.value.code == 1 + + def test_fails_leading_zero_major(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v01.2.3")) + assert exc.value.code == 1 + + def test_fails_leading_zero_minor(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v1.02.3")) + assert exc.value.code == 1 + + def test_fails_leading_zero_patch(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v1.2.03")) + assert exc.value.code == 1 + + def test_fails_rc0(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v1.2.3-rc0")) + assert exc.value.code == 1 + + def test_fails_missing_patch(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v1.2")) + assert exc.value.code == 1 + + def test_fails_yaml_extension(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="v1.2.3.yaml")) + assert exc.value.code == 1 + + def test_fails_empty(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_filename(_ns(basename="")) + assert exc.value.code == 1 + + # -- regex-level tests (belt and suspenders) -- + + @pytest.mark.parametrize("good", [ + "v0.0.0", + "v1.0.0", + "v10.20.30", + "v0.0.0-rc1", + "v1.2.3-rc99", + "v999.999.999", + ]) + def test_regex_good(self, good): + assert SEMVER_RE.match(good), f"expected {good} to match" + + @pytest.mark.parametrize("bad", [ + "", + "1.2.3", + "v01.2.3", + "v1.02.3", + "v1.2.03", + "v1.2", + "v1.2.3.4", + "v1.2.3.yaml", + "v1.2.3-rc0", + "v1.2.3-rc", + "v1.2.3-alpha", + "v1.2.3-RC1", + ]) + def test_regex_bad(self, bad): + assert SEMVER_RE.match(bad) is None, f"expected {bad} to NOT match" + + +# ── validate-yaml ──────────────────────────────────────────────────────────── + +class TestValidateYaml: + def test_good_yaml(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v1.2.3.yml", GOOD_YAML) + with pytest.raises(SystemExit) as exc: + cmd_validate_yaml(_ns(path=path)) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "commit=" in out + assert "tag=" in out + assert "✅" in out + + def test_missing_fields(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v1.2.3.yml", BAD_SCHEMA_YAML) + with pytest.raises(SystemExit) as exc: + cmd_validate_yaml(_ns(path=path)) + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "❌" in out + assert "reason" in out + + def test_bad_sha_length(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v1.2.3.yml", BAD_SHA_LENGTH_YAML) + with pytest.raises(SystemExit) as exc: + cmd_validate_yaml(_ns(path=path)) + assert exc.value.code == 1 + + def test_bad_sha_chars(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v1.2.3.yml", BAD_SHA_CHARS_YAML) + with pytest.raises(SystemExit) as exc: + cmd_validate_yaml(_ns(path=path)) + assert exc.value.code == 1 + + def test_empty_reason(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v1.2.3.yml", EMPTY_REASON_YAML) + with pytest.raises(SystemExit) as exc: + cmd_validate_yaml(_ns(path=path)) + assert exc.value.code == 1 + + def test_non_mapping_root(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v1.2.3.yml", NOT_A_MAPPING_YAML) + with pytest.raises(SystemExit) as exc: + cmd_validate_yaml(_ns(path=path)) + assert exc.value.code == 1 + + def test_file_not_found(self, capsys): + with pytest.raises(SystemExit) as exc: + cmd_validate_yaml(_ns(path="/nonexistent.yml")) + assert exc.value.code == 1 + + +# ── is-latest ──────────────────────────────────────────────────────────────── + +class TestIsLatest: + def test_highest_tag_returns_true(self, capsys): + with patch("release._run") as mock_run: + mock_run.return_value = "v1.0.0\nv1.1.0\nv2.0.0\n" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v2.0.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "true" + + def test_lower_tag_returns_false(self, capsys): + with patch("release._run") as mock_run: + mock_run.return_value = "v1.0.0\nv1.1.0\nv2.0.0\n" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v1.1.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "false" + + def test_rc_tags_excluded(self, capsys): + with patch("release._run") as mock_run: + mock_run.return_value = "v1.0.0\nv1.1.0-rc1\nv1.1.0-rc2\nv2.0.0-rc1\n" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v1.0.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "true" # v1.0.0 is highest non-RC + + def test_empty_tag_list_returns_true(self, capsys): + with patch("release._run") as mock_run: + mock_run.return_value = "" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v1.0.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "true" + + def test_only_rc_tags_returns_true(self, capsys): + with patch("release._run") as mock_run: + mock_run.return_value = "v1.0.0-rc1\nv2.0.0-rc1\n" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v3.0.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "true" + + def test_rc_tags_excluded_highest_non_rc_wins(self, capsys): + """v1.0.0 is the only non-RC, so it's the highest, not v2.0.0-rc1.""" + with patch("release._run") as mock_run: + mock_run.return_value = "v1.0.0\nv2.0.0-rc1\n" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v2.0.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "false" # v2.0.0 doesn't exist yet + # Now test the highest non-RC is v1.0.0 + with patch("release._run") as mock_run: + mock_run.return_value = "v1.0.0\nv2.0.0-rc1\n" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v1.0.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "true" # v1.0.0 IS the highest non-RC + + def test_single_tag_returns_true(self, capsys): + with patch("release._run") as mock_run: + mock_run.return_value = "v1.0.0\n" + with pytest.raises(SystemExit) as exc: + cmd_is_latest(_ns(tag="v1.0.0")) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out == "true" + + +# ── check-signatures ───────────────────────────────────────────────────────── + +class TestCheckSignatures: + def test_all_signed(self, capsys): + with ( + patch("release._run") as mock_run, + patch("release.gh_api") as mock_gh, + ): + mock_run.return_value = "v1.0.0" + mock_gh.return_value = { + "commits": [ + {"sha": "aaa", "commit": {"verification": {"verified": True}}}, + {"sha": "bbb", "commit": {"verification": {"verified": True}}}, + ] + } + with pytest.raises(SystemExit) as exc: + cmd_check_signatures(_ns(commit="abc123")) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "✅" in out + + def test_unsigned_present(self, capsys): + with ( + patch("release._run") as mock_run, + patch("release.gh_api") as mock_gh, + ): + mock_run.return_value = "v1.0.0" + mock_gh.return_value = { + "commits": [ + {"sha": "aaa", "commit": {"verification": {"verified": True}}}, + {"sha": "bbb", "commit": {"verification": {"verified": False}}}, + {"sha": "ccc", "commit": {"verification": {"verified": True}}}, + {"sha": "ddd", "commit": {"verification": {"verified": False}}}, + ] + } + with pytest.raises(SystemExit) as exc: + cmd_check_signatures(_ns(commit="abc123")) + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "bbb" in out + assert "ddd" in out + + def test_no_ancestor_tag(self, capsys): + with patch("release._run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(128, "git describe") + with pytest.raises(SystemExit) as exc: + cmd_check_signatures(_ns(commit="abc123")) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "⚠️" in out + + def test_gh_api_error(self, capsys): + with ( + patch("release._run") as mock_run, + patch("release.gh_api") as mock_gh, + ): + mock_run.return_value = "v1.0.0" + mock_gh.side_effect = GhApiError("boom") + with pytest.raises(SystemExit) as exc: + cmd_check_signatures(_ns(commit="abc123")) + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "❌" in out + + +# ── find-added-releases (tmp git repo) ─────────────────────────────────────── + +class TestFindAddedReleases: + def test_finds_added_file(self, tmp_path): + _init_git_repo(tmp_path) + _git_commit(tmp_path, "initial", files={"README.md": "hello"}) + base = _git_rev(tmp_path, "HEAD") + _git_commit(tmp_path, "add release", files={ + ".releases/v1.2.3.yml": "commit: a\nreason: test\n" + }) + head = _git_rev(tmp_path, "HEAD") + with pytest.raises(SystemExit) as exc: + cmd_find_added(_ns(base=base, head=head)) + assert exc.value.code == 0 + + def test_no_added_files(self, tmp_path, capsys): + _init_git_repo(tmp_path) + _git_commit(tmp_path, "initial", files={"README.md": "hello"}) + base = _git_rev(tmp_path, "HEAD") + _git_commit(tmp_path, "add another file", files={"other.txt": "stuff"}) + head = _git_rev(tmp_path, "HEAD") + with pytest.raises(SystemExit) as exc: + cmd_find_added(_ns(base=base, head=head)) + assert exc.value.code == 0 + + +# ── check-modifications (tmp git repo) ─────────────────────────────────────── + +class TestCheckModifications: + def test_no_modifications_passes(self, tmp_path): + os.chdir(str(tmp_path)) + _init_git_repo(tmp_path) + _git_commit(tmp_path, "initial", files={"README.md": "hello"}) + base = _git_rev(tmp_path, "HEAD") + _git_commit(tmp_path, "add unrelated", files={"other.txt": "stuff"}) + head = _git_rev(tmp_path, "HEAD") + with pytest.raises(SystemExit) as exc: + cmd_check_modifications(_ns(base=base, head=head)) + assert exc.value.code == 0 + + def test_modification_fails(self, tmp_path): + os.chdir(str(tmp_path)) + _init_git_repo(tmp_path) + _git_commit(tmp_path, "initial", files={ + ".releases/v1.0.0.yml": "commit: a\nreason: test\n" + }) + base = _git_rev(tmp_path, "HEAD") + _git_commit(tmp_path, "modify release", files={ + ".releases/v1.0.0.yml": "commit: b\nreason: modified\n" + }) + head = _git_rev(tmp_path, "HEAD") + with pytest.raises(SystemExit) as exc: + cmd_check_modifications(_ns(base=base, head=head)) + assert exc.value.code == 1 + + def test_deletion_fails(self, tmp_path): + os.chdir(str(tmp_path)) + _init_git_repo(tmp_path) + _git_commit(tmp_path, "initial", files={ + ".releases/v1.0.0.yml": "commit: a\nreason: test\n" + }) + base = _git_rev(tmp_path, "HEAD") + (tmp_path / ".releases" / "v1.0.0.yml").unlink() + subprocess.run(["git", "rm", ".releases/v1.0.0.yml"], cwd=str(tmp_path), capture_output=True) + _git_commit(tmp_path, "delete release", files={}) + head = _git_rev(tmp_path, "HEAD") + with pytest.raises(SystemExit) as exc: + cmd_check_modifications(_ns(base=base, head=head)) + assert exc.value.code == 1 + + +# ── check-commit-exists, check-tag-free ────────────────────────────────────── + +class TestCheckCommitExists: + def test_commit_exists(self, capsys): + with patch("release.gh_api") as mock_gh: + mock_gh.return_value = {"sha": "abc123"} + with pytest.raises(SystemExit) as exc: + cmd_check_commit_exists(_ns(sha="abc123")) + assert exc.value.code == 0 + + def test_commit_missing(self, capsys): + with patch("release.gh_api") as mock_gh: + mock_gh.side_effect = GhApiError("not found") + with pytest.raises(SystemExit) as exc: + cmd_check_commit_exists(_ns(sha="abc123")) + assert exc.value.code == 1 + + +class TestCheckTagFree: + def test_tag_free(self, capsys): + with patch("release.gh_api") as mock_gh: + mock_gh.side_effect = GhApiError("not found") + with pytest.raises(SystemExit) as exc: + cmd_check_tag_free(_ns(tag="v1.2.3")) + assert exc.value.code == 0 + + def test_tag_exists(self, capsys): + with patch("release.gh_api") as mock_gh: + mock_gh.return_value = {"ref": "refs/tags/v1.2.3"} + with pytest.raises(SystemExit) as exc: + cmd_check_tag_free(_ns(tag="v1.2.3")) + assert exc.value.code == 1 + + +# ── _semver_key ────────────────────────────────────────────────────────────── + +class TestSemverKey: + def test_normal(self): + assert _semver_key("v1.2.3") == (1, 2, 3, float("inf")) + assert _semver_key("v10.20.30") == (10, 20, 30, float("inf")) + + def test_rc(self): + key = _semver_key("v1.2.3-rc4") + assert key == (1, 2, 3, 4) + + def test_rc_higher_than_normal(self): + """RC versions sort before the full release of the same semver.""" + rc = _semver_key("v1.2.3-rc4") + full = _semver_key("v1.2.3") + assert rc < full # rc4's 4 < inf + + def test_sort_order(self): + tags = ["v2.0.0", "v1.10.0", "v1.2.3-rc4", "v1.2.3", "v1.2.3-rc1"] + sorted_tags = sorted(tags, key=_semver_key) + assert sorted_tags == [ + "v1.2.3-rc1", + "v1.2.3-rc4", + "v1.2.3", + "v1.10.0", + "v2.0.0", + ] + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _ns(**kwargs): + """Build a simple argparse.Namespace stand-in.""" + from types import SimpleNamespace + return SimpleNamespace(**kwargs) + + +def _cp(stdout: str = "", returncode: int = 0) -> str: + """Return value compatible with run_git (which returns stdout as str). + + Kept as a helper so existing test call sites don't have to change. + Tests using `_cp(stdout=..., returncode=...)` now just get the stdout + string; return-code handling at this boundary is already covered by + CalledProcessError side_effect patterns elsewhere. + """ + return stdout + + +class ReleaseAPIError_DEPRECATED(Exception): + """Unused — kept as placeholder. Tests now use GhApiError from release.""" + + +# git helpers for tmp-dir based tests + +def _init_git_repo(path: Path) -> None: + subprocess.run(["git", "init"], cwd=str(path), capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=str(path), capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=str(path), capture_output=True, + ) + + +def _git_commit(path: Path, msg: str, files: dict[str, str]) -> None: + for relpath, content in files.items(): + full = path / relpath + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content) + subprocess.run(["git", "add", relpath], cwd=str(path), capture_output=True) + subprocess.run(["git", "commit", "-m", msg], cwd=str(path), capture_output=True) + + +def _git_rev(path: Path, ref: str) -> str: + r = subprocess.run( + ["git", "rev-parse", ref], + cwd=str(path), capture_output=True, text=True, + ) + return r.stdout.strip() diff --git a/.github/workflows/validate-release-request.yml b/.github/workflows/validate-release-request.yml new file mode 100644 index 00000000..701e2110 --- /dev/null +++ b/.github/workflows/validate-release-request.yml @@ -0,0 +1,28 @@ +name: Validate Release Request +on: + pull_request: + +jobs: + validate: + name: validate-release-request + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python deps + run: pip install pyyaml + + - name: Validate release request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python .github/workflows/release/release.py validate-pr diff --git a/.gitignore b/.gitignore index e48792b4..000e6573 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ targets.json .idea/ logs .vscode/ + +# Python (release scripts under .github/workflows/release/) +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/.releases/.gitkeep b/.releases/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.releases/README.md b/.releases/README.md new file mode 100644 index 00000000..d63d6ea7 --- /dev/null +++ b/.releases/README.md @@ -0,0 +1,65 @@ +# Release Requests + +This directory contains release-request YAML files. Adding a new file here triggers a release. + +## Filing a release request + +1. Pick a commit SHA to release +2. Create a file at `.releases/.yml` where the filename (minus `.yml`) is the exact tag to create +3. File contents: +```yaml + commit: <40-character SHA> + reason: "" +``` +4. In the same PR: + - Update `CHANGELOG.md` + - Bump the root `Cargo.toml` workspace version to `-dev` +5. Open the PR, get two approvals, squash-merge + +## Filename rules + +- Full release: `v1.2.3.yml` +- Pre-release: `v1.2.3-rc1.yml`, `v1.2.3-rc2.yml`, etc. +- Must start with `v` +- Must be valid semver (or `-rcN` suffix) +- Must use `.yml` extension + +## Constraints (enforced by CI) + +- Exactly one release YAML may be added per PR +- Existing YAMLs cannot be modified or deleted +- The referenced commit must exist in the repository (on any branch) +- The tag must not already exist + +## What happens after merge + +1. `release-gate.yml` creates the signed tag at the referenced commit +2. `release.yml` builds artifacts from the tagged commit and publishes the release +3. `:latest` on GHCR is updated only if the new tag is the highest non-RC semver + +## Reviewer checklist + +Before approving a release-request PR, confirm: + +- The commit SHA points at the intended code +- The commit shows as "Verified" in GitHub's UI +- **For hotfixes:** click through every commit on the fix branch from the last release tag to the release commit and verify each shows "Verified." Unsigned commits in the ancestry will cause CI to fail, but visual confirmation during review is faster feedback than waiting for CI. +- The version number makes sense (greater than the last release on this line) +- `CHANGELOG.md` has been updated with release notes +- `Cargo.toml` workspace version is bumped to the next `-dev` value +- The `reason` field accurately describes why this release is being cut + +## Emergency / hotfix releases + +1. Create a fix branch from the last release tag: `git checkout -b fix/ vX.Y.Z` +2. Apply fixes via normal PRs into the fix branch +3. File a release-request YAML on main pointing at the fix branch's tip commit +4. After release ships, reconcile the fix branch into main via a normal PR + +## Closed-without-merging PRs + +If a release-request PR is closed without merging, no release occurs. The validator's failure (if any) is informational; nothing happens downstream. + +## Testing on a fork + +The release process requires a GitHub App to function. Install a personal GitHub App on your fork with `contents: write` permission, generate a private key, and add `APP_ID` and `APP_PRIVATE_KEY` as repository secrets. The workflows then run end-to-end on your fork without any file edits.