From ea4f6e4abc76d0722a8040d1d3001f908fe1be17 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Fri, 24 Apr 2026 22:56:18 +0100 Subject: [PATCH 01/19] new release process (#34) --- .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. From 56e38b84c09c6c5ca6f10291106d32c12fac7c87 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Fri, 24 Apr 2026 23:29:47 +0100 Subject: [PATCH 02/19] Update release (#35) * new release process * add linting / better readme --- .github/workflows/release/README.md | 217 +++++++++++++++++----- .github/workflows/release/release.py | 47 ++++- .github/workflows/release/test_release.py | 53 ++++++ 3 files changed, 265 insertions(+), 52 deletions(-) diff --git a/.github/workflows/release/README.md b/.github/workflows/release/README.md index 4944bef6..207a6b5b 100644 --- a/.github/workflows/release/README.md +++ b/.github/workflows/release/README.md @@ -1,86 +1,207 @@ -# Release Management Scripts +# Release automation -Python CLI that backs the three release workflows: +Python scripts that drive the Commit-Boost release workflow. Run by CI on every release-request PR and on every release tag — but also runnable locally for development, debugging, and dry-runs. -- `.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 ` +The user-facing release procedure lives in [`.releases/README.md`](../../../.releases/README.md). Read that first if you're cutting a release. This README is for people working on the release infrastructure itself. -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. +## How a release happens -## Requirements +``` + ┌────────────────────────────────────────────┐ + │ 1. Maintainer opens a PR adding │ + │ .releases/v1.2.3.yml │ + │ (40-char commit SHA + reason) │ + └──────────────┬─────────────────────────────┘ + │ + ┌──────────────▼─────────────────────────────┐ + │ 2. validate-release-request.yml runs │ + │ → release.py validate-pr │ + │ Checks: filename, schema, commit exists,│ + │ tag is free, ancestor commits signed │ + └──────────────┬─────────────────────────────┘ + │ (2 approvals, squash-merge) + ┌──────────────▼─────────────────────────────┐ + │ 3. release-gate.yml runs (post-merge) │ + │ → release.py gate │ + │ Creates signed tag via GitHub API │ + │ (POST /git/tags + POST /git/refs) │ + └──────────────┬─────────────────────────────┘ + │ (tag push triggers next workflow) + ┌──────────────▼─────────────────────────────┐ + │ 4. release.yml runs (on tag push) │ + │ Builds binaries (linux x64+arm64, │ + │ darwin arm64), pushes Docker images, │ + │ drafts GitHub Release. determine-latest │ + │ job calls release.py is-latest to │ + │ decide whether to update :latest │ + └────────────────────────────────────────────┘ +``` -- Python 3.10+ (tested on 3.12) -- `pyyaml` -- `gh` CLI (authenticated via `GH_TOKEN` env for API calls) -- `git` (for repo-local queries) +## Example release-request YAML +The filename is the tag. The contents reference the commit to tag. + +`.releases/v0.9.7.yml`: +```yaml +commit: a1b2c3d4e5f6789012345678901234567890abcd +reason: "Emergency hotfix for ..." ``` -pip install pyyaml pytest + +`.releases/v1.0.0-rc1.yml`: +```yaml +commit: 0123456789abcdef0123456789abcdef01234567 +reason: "First release candidate of the 1.0 line" ``` -## Running the tests +Naming rules: `v..` or `v..-rc`. No leading zeros. Must use `.yml` (not `.yaml`). + +## Filing a release (the short version) + +1. Pick the commit SHA you want to ship. +2. Create `.releases/v.yml` with `commit:` and `reason:`. +3. In the same PR: bump `Cargo.toml` workspace version to `-dev`, update `CHANGELOG.md`. +4. Two approvals, squash-merge. +5. Watch `release-gate.yml` create the tag, then `release.yml` build artifacts. + +Hotfix variant: cut a `fix/*` branch from the last release tag, land fixes via squash-merge, then file the YAML on `main` pointing at the fix-branch tip. The release ships from that commit even though it's not in main's history. After the release, merge the fix branch back into main via a normal PR. + +Full procedure including the reviewer checklist: [`.releases/README.md`](../../../.releases/README.md). + +## Setting up Python locally + +Use [uv](https://docs.astral.sh/uv/) — fastest path, no virtualenv ceremony. + +### Install uv ``` -pytest .github/workflows/release/test_release.py -v +# macOS / Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# or via Homebrew +brew install uv ``` -All 61 tests use mocked `subprocess.run` boundaries and a tmp-repo fixture — they don't touch the network. +### Run the test suite + +uv handles Python and dependencies in one shot. From the repo root: -## Subcommands +``` +uv run --with pyyaml --with pytest pytest .github/workflows/release/test_release.py -v +``` -Quick reference — each subcommand exits 0 on success, non-zero on failure, and prints `❌` / `✅` messages to stdout. +That command provisions a Python environment with `pyyaml` and `pytest`, then runs the suite. No `venv activate`, no `pip install`, no system Python pollution. Expected output: `61 passed`. -| 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 | +### Run a subcommand -## Running locally +``` +uv run --with pyyaml python .github/workflows/release/release.py --help +uv run --with pyyaml python .github/workflows/release/release.py validate-filename v1.2.3 +``` -Most subcommands need env vars: +### Without uv (if you must) + +``` +python3 -m venv .venv +source .venv/bin/activate +pip install pyyaml pytest +pytest .github/workflows/release/test_release.py -v +``` + +CI uses the explicit `setup-python` + `pip install pyyaml` pattern in the workflow files (no uv on the runners; pyyaml is the only runtime dep). + +## Pre-commit sanity check + +Before opening a release-request PR, run the same checks CI will run. One command: ``` 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 +uv run --with pyyaml python .github/workflows/release/release.py \ + lint .releases/v1.2.3.yml +``` + +`lint` runs the full validation pipeline against a single YAML: filename regex, schema, commit-exists, tag-free, ancestor-signature check. Exit 0 means CI will be happy. Exit non-zero shows the exact error CI will print. + +Add this to your shell rc for repeated dry-runs: + +```bash +cb-release-check() { + export REPO=commit-boost/commit-boost-client + export GH_TOKEN=$(gh auth token) + uv run --with pyyaml python .github/workflows/release/release.py lint "$1" +} +``` + +Then `cb-release-check .releases/v1.2.3.yml` from the repo root. + +## Subcommand reference + +Every subcommand exits 0 on success, non-zero on failure. Output uses ❌ for errors and ✅ for success. + +| Command | Purpose | Used by | +| --- | --- | --- | +| `validate-filename ` | Strict semver check. Rejects leading zeros, missing `v`, wrong extension. | `validate-pr` | +| `validate-yaml ` | Parse + schema-check a release-request YAML. Prints `tag=...` `commit=...` on success. | `validate-pr`, `gate` | +| `find-added --base --head ` | List `.releases/*.yml` files added in a diff range. | `validate-pr`, `gate` | +| `check-modifications --base --head ` | Reject modifications/deletions of existing YAMLs. | `validate-pr` | +| `check-commit-exists ` | Verify a commit exists via `gh api /commits/{sha}`. | `validate-pr` | +| `check-tag-free ` | Verify the tag does not already exist. | `validate-pr` | +| `check-signatures ` | Confirm every commit from the nearest tag ancestor to `` has a verified signature. | `validate-pr` | +| `create-tag ` | Create signed annotated tag via `POST /git/tags` + `POST /git/refs`. GitHub server-signs using the App identity. | `gate` | +| `is-latest ` | Print `true` if `` is the highest non-RC semver among local `v*` tags, else `false`. | `release.yml` `determine-latest` job | +| `validate-pr` | End-to-end PR validator. Reads `BASE_SHA`, `HEAD_SHA`, `GH_TOKEN`, `REPO` from env. | `validate-release-request.yml` | +| `gate` | End-to-end gate. Reads `BASE_SHA`, `MERGE_SHA`, `GH_TOKEN`, `REPO` from env. | `release-gate.yml` | +| `lint ` | Pre-commit sanity check on a single YAML. Same checks as CI minus the diff step. Reads `GH_TOKEN`, `REPO` from env. | local dev only | + +### Local dry-run examples -# Is v1.2.3 the latest? -python .github/workflows/release/release.py is-latest v1.2.3 +See [Pre-commit sanity check](#pre-commit-sanity-check) above for the canonical recipe — it covers most needs. -# 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 +For ad-hoc one-offs: + +``` +# Check is-latest against your current tag set +uv run --with pyyaml python .github/workflows/release/release.py is-latest v0.9.7 + +# Find what would be added between two refs (without validating) +uv run --with pyyaml python .github/workflows/release/release.py \ + find-added --base origin/main --head HEAD ``` ## Layout ``` .github/workflows/release/ -├── release.py # The CLI -├── test_release.py # pytest suite (unit + tmp-repo integration) +├── release.py # The CLI (argparse, ~370 lines) +├── test_release.py # pytest suite (~540 lines, 61 tests) └── 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. +Located alongside the workflow files that call it. This follows the convention used by the [ethereum-package Kurtosis repo](https://github.com/ethpandaops/ethereum-package), which keeps workflow-supporting Python under `.github/workflows/`. + ## 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. +- **Single-file script, no packaging.** Keeps invocation simple — `python release.py ` works from anywhere. +- **Two patchable boundaries.** `run_git()` for git, `gh_api()` for GitHub API. Tests stub these and skip the network entirely. `_run()` handles raw subprocess mechanics. +- **Errors prefixed `❌`, success `✅`.** Matches the conventions of the inline shell the workflows used to use, so log readers see consistent markers. +- **`SEMVER_RE` lives in `release.py`** and is imported by tests. Single source of truth for what counts as a valid release tag. +- **No deps beyond PyYAML.** `gh` and `git` are CLI tools, not Python packages — they're already on every GitHub runner and on every developer's machine. + +## What's NOT in here + +- Tag-signing keys: GitHub server-signs via the App identity when we POST to `/git/tags`. No GPG setup required on the runner. +- Branch protection rules: configured in repo Settings, not in code. See `PLAN.md` for the ruleset spec. +- The actual binary/docker build pipeline: that's in `.github/workflows/release.yml` and remains conventional GitHub Actions YAML. + +## Troubleshooting + +**`pip install pyyaml` fails with "externally-managed-environment"** — you're hitting PEP 668 on Ubuntu 24.04+. Use uv (above) or `pip install --user pyyaml`. CI uses `actions/setup-python@v5` to sidestep this. + +**`gh: not found`** — install the GitHub CLI: `brew install gh` then `gh auth login`. Subcommands that hit the API need `GH_TOKEN` in the environment; `$(gh auth token)` works. + +**`check-signatures` says "No prior tag ancestor found"** — `git describe --tags --abbrev=0 ^` couldn't find a reachable tag. Either you're at the very first release, or your local clone is shallow. `git fetch --tags --unshallow` and retry. + +**Tests fail with `ImportError: cannot import name 'cmd_xxx' from 'release'`** — you've added a subcommand to release.py but forgot to wire it into `main()`'s subparser, or the test imports a stale name. The tests import every command function explicitly; keep the names in sync. diff --git a/.github/workflows/release/release.py b/.github/workflows/release/release.py index 708db930..dc504630 100644 --- a/.github/workflows/release/release.py +++ b/.github/workflows/release/release.py @@ -264,6 +264,21 @@ def cmd_is_latest(args: argparse.Namespace) -> None: sys.exit(0) +def _step(fn, args: argparse.Namespace) -> None: + """Run a cmd_* function as a step inside an orchestrator. + + The cmd_* functions all call ``sys.exit(0)`` on success, which would + short-circuit any orchestrator that chains them. This wrapper catches + SystemExit(0) so the next step can run, while letting non-zero exits + propagate (orchestrator should abort on failure). + """ + try: + fn(args) + except SystemExit as e: + if e.code not in (0, None): + raise + + def cmd_validate_pr(args: argparse.Namespace) -> None: base = _env("BASE_SHA") head = _env("HEAD_SHA") @@ -291,11 +306,11 @@ def cmd_validate_pr(args: argparse.Namespace) -> None: filepath = added[0] basename = Path(filepath).stem - cmd_validate_filename(argparse.Namespace(basename=basename)) + _step(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)) + _step(cmd_check_commit_exists, argparse.Namespace(sha=commit)) + _step(cmd_check_tag_free, argparse.Namespace(tag=basename)) + _step(cmd_check_signatures, argparse.Namespace(commit=commit)) print(f"added_count=1") print(f"tag={basename}") @@ -317,6 +332,26 @@ def cmd_gate(args: argparse.Namespace) -> None: cmd_create_tag(argparse.Namespace(tag=tag, commit=commit)) +def cmd_lint(args: argparse.Namespace) -> None: + """Pre-commit sanity check: run every CI validation against a single YAML. + + Reads $REPO and $GH_TOKEN like validate-pr does, but skips the git-diff + step — we already know which file you're checking. Use this before + opening a release-request PR to confirm CI will accept it. + """ + path = args.path + basename = Path(path).stem + + print(f"── Linting {path} ──") + _step(cmd_validate_filename, argparse.Namespace(basename=basename)) + commit, _ = validate_yaml_file(path) + _step(cmd_check_commit_exists, argparse.Namespace(sha=commit)) + _step(cmd_check_tag_free, argparse.Namespace(tag=basename)) + _step(cmd_check_signatures, argparse.Namespace(commit=commit)) + print(f"✅ {path} would pass CI.") + sys.exit(0) + + # ── main ───────────────────────────────────────────────────────────────────── def main() -> None: @@ -368,6 +403,10 @@ def main() -> None: p = sub.add_parser("gate", help="End-to-end gate after merge (reads env)") p.set_defaults(func=cmd_gate) + p = sub.add_parser("lint", help="Pre-commit sanity check on a single YAML (reads $REPO + $GH_TOKEN)") + p.add_argument("path", help="Path to the release-request YAML to lint") + p.set_defaults(func=cmd_lint) + parsed = parser.parse_args() parsed.func(parsed) diff --git a/.github/workflows/release/test_release.py b/.github/workflows/release/test_release.py index f6cd766b..c5dc0456 100644 --- a/.github/workflows/release/test_release.py +++ b/.github/workflows/release/test_release.py @@ -21,6 +21,7 @@ cmd_check_commit_exists, cmd_check_tag_free, GhApiError, + cmd_lint, ) HERE = Path(__file__).parent @@ -541,3 +542,55 @@ def _git_rev(path: Path, ref: str) -> str: cwd=str(path), capture_output=True, text=True, ) return r.stdout.strip() + + +# ── lint ───────────────────────────────────────────────────────────────────── + +class TestLint: + def test_lint_full_pass(self, tmp_path, capsys): + """Happy path: filename ok, YAML ok, commit exists, tag free, all signed.""" + path = _write_yaml(tmp_path, "v1.2.3.yml", GOOD_YAML) + with ( + patch("release.run_git") as mock_git, + patch("release.gh_api") as mock_gh, + ): + # check-signatures path: prev tag, then compare with all signed + mock_git.return_value = "v1.0.0" + # check-commit-exists, check-tag-free (raises = free), compare + def gh_side_effect(method, path_, **kwargs): + if "/commits/" in path_: + return {"sha": "abcdef" * 6 + "abcdefgh"} + if "/git/refs/tags/" in path_: + raise GhApiError("not found") + if "/compare/" in path_: + return {"commits": [ + {"sha": "a", "commit": {"verification": {"verified": True}}}, + ]} + raise AssertionError(f"unexpected gh_api path: {path_}") + mock_gh.side_effect = gh_side_effect + with pytest.raises(SystemExit) as exc: + cmd_lint(_ns(path=path)) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "Linting" in out + assert "would pass CI" in out + + def test_lint_bad_filename_fails_early(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v01.02.03.yml", GOOD_YAML) # leading zeros + with pytest.raises(SystemExit) as exc: + cmd_lint(_ns(path=path)) + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "❌" in out + assert "would pass CI" not in out + + def test_lint_bad_yaml_fails(self, tmp_path, capsys): + path = _write_yaml(tmp_path, "v1.2.3.yml", BAD_SCHEMA_YAML) + with pytest.raises(SystemExit) as exc: + cmd_lint(_ns(path=path)) + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "❌" in out + assert "reason" in out + + From 72f9cee768e94f26a1a1a24c502783deebf6135c Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 24 Apr 2026 15:30:49 -0700 Subject: [PATCH 03/19] Create v2.0.4.yml --- .releases/v2.0.4.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.0.4.yml diff --git a/.releases/v2.0.4.yml b/.releases/v2.0.4.yml new file mode 100644 index 00000000..6c5cd1e5 --- /dev/null +++ b/.releases/v2.0.4.yml @@ -0,0 +1,2 @@ +commit: ea4f6e4abc76d0722a8040d1d3001f908fe1be17 +reason: "Test" From ab9b14424d74670f16f07050e50e8a3ab22369ca Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Fri, 24 Apr 2026 23:35:12 +0100 Subject: [PATCH 04/19] Create v2.0.5.yml (#36) --- .releases/v2.0.5.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.0.5.yml diff --git a/.releases/v2.0.5.yml b/.releases/v2.0.5.yml new file mode 100644 index 00000000..92c049c9 --- /dev/null +++ b/.releases/v2.0.5.yml @@ -0,0 +1,2 @@ +commit: 56e38b84c09c6c5ca6f10291106d32c12fac7c87 +reason: "Test" From 51fff8c055e012cf5f7b11a394619dc318040cee Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Fri, 24 Apr 2026 23:59:23 +0100 Subject: [PATCH 05/19] Update release (#37) * new release process * add linting / better readme * bump action versions for warnings + stricter semver checks --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/release-gate.yml | 6 ++-- .github/workflows/release.yml | 12 ++++---- .github/workflows/release/release.py | 28 +++++++++++++++---- .github/workflows/release/test_release.py | 7 +++++ .github/workflows/security-audit.yml | 2 +- .github/workflows/test-docs.yml | 2 +- .../workflows/validate-release-request.yml | 4 +-- 9 files changed, 44 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 381d4e12..d9d33ad6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ec98a1ff..a23cbd7e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,7 +12,7 @@ jobs: name: Build Docusaurus runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 13ca8e60..40892102 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -23,17 +23,17 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/create-github-app-token@v1 + - uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.x' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02515357..959658a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,12 +18,12 @@ jobs: outputs: value: ${{ steps.is_latest.outputs.value }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Fetch tags - run: git fetch --tags + run: git fetch --tags --force - name: Check is-latest id: is_latest @@ -57,7 +57,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: true @@ -131,7 +131,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: true @@ -191,7 +191,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: true @@ -247,7 +247,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: true diff --git a/.github/workflows/release/release.py b/.github/workflows/release/release.py index dc504630..89dbded3 100644 --- a/.github/workflows/release/release.py +++ b/.github/workflows/release/release.py @@ -86,10 +86,17 @@ def _git_diff(base: str, head: str, diff_filter: str) -> list[str]: def _semver_key(tag: str) -> tuple: - """Return a comparable key for a semver tag (e.g. ``v1.2.3``).""" + """Return a comparable key for a strict-semver release tag. + + Strict means: matches SEMVER_RE exactly. Non-strict input raises ValueError + so callers can fail loudly rather than silently mis-sort. + + Sort order: (major, minor, patch, rc_or_inf). Final releases sort above + their RC siblings via float('inf') — i.e. v1.2.3 > v1.2.3-rc99. + """ + if not SEMVER_RE.match(tag): + raise ValueError(f"Not a strict-semver release tag: {tag!r}") 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"), @@ -250,16 +257,25 @@ def cmd_create_tag(args: argparse.Namespace) -> None: def cmd_is_latest(args: argparse.Namespace) -> None: tag = args.tag + # Fail-closed: the tag we're releasing must itself be strict semver. + # validate-filename already enforces this for new releases, but defend + # in depth in case is-latest is invoked standalone. + if not SEMVER_RE.match(tag): + print(f"❌ Tag {tag!r} is not strict semver. Cannot determine is-latest.") + sys.exit(1) 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: + # Only strict-semver, non-RC tags participate in the comparison. + # Legacy malformed tags (v0.7.0-rc.1, v0.9.2-rc-dev, v2.0.0-rc2-1, etc.) + # are invisible to the highest-wins check. + candidates = [t for t in all_tags if t and SEMVER_RE.match(t) and "-rc" not in t] + if not candidates: print("true") sys.exit(0) - highest = sorted(non_rc, key=_semver_key)[-1] + highest = max(candidates, key=_semver_key) print("true" if highest == tag else "false") sys.exit(0) diff --git a/.github/workflows/release/test_release.py b/.github/workflows/release/test_release.py index c5dc0456..16a3920e 100644 --- a/.github/workflows/release/test_release.py +++ b/.github/workflows/release/test_release.py @@ -489,6 +489,13 @@ def test_sort_order(self): "v2.0.0", ] + def test_rejects_non_strict(self): + """_semver_key fails loudly on tags that don't match SEMVER_RE.""" + for bad in ["v0.7.0-rc.1", "v0.9.2-rc-dev", "v2.0.0-rc2-1", + "v01.02.03", "1.2.3", "garbage"]: + with pytest.raises(ValueError): + _semver_key(bad) + # ── helpers ────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 2a40d1ac..60bc3ea9 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -18,7 +18,7 @@ jobs: security_audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions-rs/audit-check@v1.2.0 with: token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index c4d18bdf..6cb3e4d9 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -12,7 +12,7 @@ jobs: name: Test deployment runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/validate-release-request.yml b/.github/workflows/validate-release-request.yml index 701e2110..018f112b 100644 --- a/.github/workflows/validate-release-request.yml +++ b/.github/workflows/validate-release-request.yml @@ -8,11 +8,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.x' From 03d9902798ecc9029ceb117b576ee81855661540 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 00:01:29 +0100 Subject: [PATCH 06/19] Create v2.0.6.yml (#38) --- .releases/v2.0.6.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.0.6.yml diff --git a/.releases/v2.0.6.yml b/.releases/v2.0.6.yml new file mode 100644 index 00000000..e9800eaf --- /dev/null +++ b/.releases/v2.0.6.yml @@ -0,0 +1,2 @@ +commit: 51fff8c055e012cf5f7b11a394619dc318040cee +reason: "Test" From cabb46a60dcee9788ef3a9b05e43a83dd3d16d45 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 00:26:45 +0100 Subject: [PATCH 07/19] Update release (#39) * new release process * add linting / better readme * bump action versions for warnings + stricter semver checks * binary signing --- .github/workflows/release.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 959658a3..dabadc05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: permissions: contents: write packages: write + id-token: write jobs: # Determines whether this tag should update :latest on GHCR. @@ -296,6 +297,29 @@ jobs: ${{ needs.determine-latest.outputs.value == 'true' && format('ghcr.io/{0}/signer:latest', env.OWNER) || '' }} file: provisioning/signer.Dockerfile + # Signs all Linux binaries with Sigstore for provenance + sign-binaries: + needs: [build-binaries-linux] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + pattern: "commit-boost-*linux*" + + - name: Sign all binaries with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 + with: + inputs: ./artifacts/**/*.tar.gz + + - name: Upload signed artifacts + uses: actions/upload-artifact@v4 + with: + name: signed-${{ github.ref_name }} + path: ./artifacts/**/* + # Creates a draft release on GitHub with the binaries finalize-release: needs: @@ -303,6 +327,7 @@ jobs: - build-binaries-darwin - build-and-push-pbs-docker - build-and-push-signer-docker + - sign-binaries runs-on: ubuntu-latest timeout-minutes: 60 steps: From 9dc2b4eaf437242f1520eeeae57aa3dbc55fa0ad Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 00:27:46 +0100 Subject: [PATCH 08/19] Create v2.0.7.yml (#40) --- .releases/v2.0.7.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.0.7.yml diff --git a/.releases/v2.0.7.yml b/.releases/v2.0.7.yml new file mode 100644 index 00000000..e0fb7f3a --- /dev/null +++ b/.releases/v2.0.7.yml @@ -0,0 +1,2 @@ +commit: cabb46a60dcee9788ef3a9b05e43a83dd3d16d45 +reason: "Test" From 321dfbad87378155dc8d62bd067e86efba8f7f38 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 02:34:03 +0100 Subject: [PATCH 09/19] Update release (#41) * new release process * add linting / better readme * bump action versions for warnings + stricter semver checks * binary signing * - finalize-release downloads both the raw binaries AND the signed .sigstore.json bundles, and attaches everything to the draft release - fix chained ci cmds - dedup docker build cmds --- .github/workflows/release.yml | 123 +++++++++++++-------------- .github/workflows/release/release.py | 50 +++++++++-- README.md | 34 ++++++++ 3 files changed, 135 insertions(+), 72 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dabadc05..e9522a9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,31 @@ permissions: id-token: write jobs: + check-ci-status: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install Python deps + run: pip install pyyaml + + - name: Verify CI passed for this commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: python .github/workflows/release/release.py check-ci "${{ github.sha }}" + # Determines whether this tag should update :latest on GHCR. - # Runs once; both Docker jobs consume its output. + # Runs once; the Docker job consumes its output via matrix. determine-latest: + needs: [check-ci-status] runs-on: ubuntu-latest timeout-minutes: 2 outputs: @@ -23,9 +45,6 @@ jobs: with: fetch-depth: 0 - - name: Fetch tags - run: git fetch --tags --force - - name: Check is-latest id: is_latest run: | @@ -34,6 +53,7 @@ jobs: # Builds the x64 and arm64 binaries for Linux, for all 3 crates, via the Docker builder build-binaries-linux: + needs: [check-ci-status] timeout-minutes: 60 strategy: matrix: @@ -111,6 +131,7 @@ jobs: # Builds the arm64 binaries for Darwin, for all 3 crates, natively build-binaries-darwin: + needs: [check-ci-status] timeout-minutes: 60 strategy: matrix: @@ -185,9 +206,12 @@ jobs: path: | ${{ matrix.name }}-${{ github.ref_name }}-darwin_${{ matrix.package-suffix }}.tar.gz - # Builds the PBS Docker image - build-and-push-pbs-docker: - needs: [build-binaries-linux, determine-latest] + # Builds and pushes Docker images for both PBS and Signer + build-and-push-docker: + needs: [check-ci-status, build-binaries-linux, determine-latest] + strategy: + matrix: + crate: [pbs, signer] runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -207,10 +231,10 @@ jobs: run: | mkdir -p ./artifacts/bin/linux_amd64 mkdir -p ./artifacts/bin/linux_arm64 - tar -xzf ./artifacts/commit-boost-pbs-${{ github.ref_name }}-linux_x86-64/commit-boost-pbs-${{ github.ref_name }}-linux_x86-64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-pbs ./artifacts/bin/linux_amd64/commit-boost-pbs - 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 + tar -xzf ./artifacts/commit-boost-${{ matrix.crate }}-${{ github.ref_name }}-linux_x86-64/commit-boost-${{ matrix.crate }}-${{ github.ref_name }}-linux_x86-64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost-${{ matrix.crate }} ./artifacts/bin/linux_amd64/commit-boost-${{ matrix.crate }} + tar -xzf ./artifacts/commit-boost-${{ matrix.crate }}-${{ github.ref_name }}-linux_arm64/commit-boost-${{ matrix.crate }}-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost-${{ matrix.crate }} ./artifacts/bin/linux_arm64/commit-boost-${{ matrix.crate }} - name: Set lowercase owner run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV @@ -228,7 +252,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push PBS Docker image + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . @@ -237,65 +261,32 @@ jobs: build-args: | BINARIES_PATH=./artifacts/bin tags: | - 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 + ghcr.io/${{ env.OWNER }}/${{ matrix.crate }}:${{ github.ref_name }} + ${{ needs.determine-latest.outputs.value == 'true' && format('ghcr.io/{0}/{1}:latest', env.OWNER, matrix.crate) || '' }} + file: provisioning/${{ matrix.crate }}.Dockerfile - # Builds the Signer Docker image - build-and-push-signer-docker: - needs: [build-binaries-linux, determine-latest] + # Signs all Linux binaries with Sigstore for provenance + sign-binaries: + needs: [check-ci-status, build-binaries-linux] runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 10 steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: true - - - name: Download binary archives + - name: Download binary artifacts uses: actions/download-artifact@v4 with: path: ./artifacts - pattern: "commit-boost-*" - - - name: Extract binaries - run: | - mkdir -p ./artifacts/bin/linux_amd64 - mkdir -p ./artifacts/bin/linux_arm64 - tar -xzf ./artifacts/commit-boost-signer-${{ github.ref_name }}-linux_x86-64/commit-boost-signer-${{ github.ref_name }}-linux_x86-64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-signer ./artifacts/bin/linux_amd64/commit-boost-signer - 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 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + pattern: "commit-boost-*linux*" - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + - name: Sign all binaries with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + inputs: ./artifacts/**/*.tar.gz - - name: Build and push Signer Docker image - uses: docker/build-push-action@v6 + - name: Upload signed artifacts + uses: actions/upload-artifact@v4 with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - build-args: | - BINARIES_PATH=./artifacts/bin - tags: | - 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 + name: signed-${{ github.ref_name }} + path: ./artifacts/**/* # Signs all Linux binaries with Sigstore for provenance sign-binaries: @@ -323,19 +314,21 @@ jobs: # Creates a draft release on GitHub with the binaries finalize-release: needs: + - check-ci-status - build-binaries-linux - build-binaries-darwin - - build-and-push-pbs-docker - - build-and-push-signer-docker + - build-and-push-docker - sign-binaries runs-on: ubuntu-latest timeout-minutes: 60 steps: - - name: Download artifacts + - name: Download artifacts (binaries + signed bundles) uses: actions/download-artifact@v4 with: path: ./artifacts - pattern: "commit-boost-*" + pattern: | + commit-boost-* + signed-* - name: Finalize Release uses: softprops/action-gh-release@v2 diff --git a/.github/workflows/release/release.py b/.github/workflows/release/release.py index 89dbded3..d8b6ea57 100644 --- a/.github/workflows/release/release.py +++ b/.github/workflows/release/release.py @@ -13,11 +13,10 @@ 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) @@ -78,6 +77,16 @@ def _git_diff(base: str, head: str, diff_filter: str) -> list[str]: return [l for l in out.strip().split("\n") if l] +def find_added(base: str, head: str) -> list[str]: + """Return .releases/*.yml files added between two refs.""" + return _git_diff(base, head, "A") + + +def find_modified_deleted(base: str, head: str) -> list[str]: + """Return .releases/*.yml files modified or deleted between two refs.""" + return _git_diff(base, head, "MD") + + # ── core validation helpers ───────────────────────────────────────────────── SEMVER_RE = re.compile( @@ -111,6 +120,7 @@ def validate_yaml_file(path: str) -> tuple[str, str]: print(f"❌ File not found: {path}") sys.exit(1) + import yaml try: data = yaml.safe_load(text) except yaml.YAMLError as e: @@ -166,7 +176,7 @@ def cmd_validate_yaml(args: argparse.Namespace) -> None: def cmd_find_added(args: argparse.Namespace) -> None: - files = _git_diff(args.base, args.head, "A") + files = find_added(args.base, args.head) for f in files: print(f) print(f"count={len(files)}", file=sys.stderr) @@ -174,7 +184,7 @@ def cmd_find_added(args: argparse.Namespace) -> None: def cmd_check_modifications(args: argparse.Namespace) -> None: - files = _git_diff(args.base, args.head, "MD") + files = find_modified_deleted(args.base, args.head) if files: print("❌ Existing release YAMLs cannot be modified or deleted:") for f in files: @@ -299,8 +309,8 @@ 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") + added = find_added(base, head) + mods = find_modified_deleted(base, head) if mods: print("❌ Existing release YAMLs cannot be modified or deleted:") @@ -338,7 +348,7 @@ def cmd_gate(args: argparse.Namespace) -> None: base = _env("BASE_SHA") merge_sha = _env("MERGE_SHA") - added = _git_diff(base, merge_sha, "A") + added = find_added(base, merge_sha) if len(added) != 1: print(f"Expected exactly 1 added release YAML, got {len(added)}. Skipping.") sys.exit(0) @@ -348,6 +358,28 @@ def cmd_gate(args: argparse.Namespace) -> None: cmd_create_tag(argparse.Namespace(tag=tag, commit=commit)) +def cmd_check_ci(args: argparse.Namespace) -> None: + """Check CI status for a given commit SHA.""" + sha = args.sha + data = gh_api("GET", f"/commits/{sha}/check-runs?per_page=100") + runs = data.get("check_runs", []) + + completed = [r for r in runs if r.get("status") == "completed"] + if not completed: + print(f"⚠️ No completed CI checks for commit {sha}. Proceeding without verification.") + sys.exit(0) + + failures = [(r["name"], r["conclusion"]) for r in completed if r.get("conclusion") != "success"] + if failures: + print(f"❌ CI check failed for commit {sha}:") + for name, conclusion in failures: + print(f" {name}: {conclusion}") + sys.exit(1) + + print(f"✅ All CI checks passed for commit {sha}") + sys.exit(0) + + def cmd_lint(args: argparse.Namespace) -> None: """Pre-commit sanity check: run every CI validation against a single YAML. @@ -419,6 +451,10 @@ def main() -> None: p = sub.add_parser("gate", help="End-to-end gate after merge (reads env)") p.set_defaults(func=cmd_gate) + p = sub.add_parser("check-ci", help="Check CI status for a given commit SHA") + p.add_argument("sha", help="Commit SHA to check") + p.set_defaults(func=cmd_check_ci) + p = sub.add_parser("lint", help="Pre-commit sanity check on a single YAML (reads $REPO + $GH_TOKEN)") p.add_argument("path", help="Path to the release-request YAML to lint") p.set_defaults(func=cmd_lint) diff --git a/README.md b/README.md index c28c28b5..25e1bbc5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,40 @@ Commit-Boost is a modular sidecar that allows Ethereum validators to opt-in to d ## Audit Commit-Boost received an audit from [Sigma Prime](https://sigmaprime.io/). Find the report [here](/audit/Sigma_Prime_Commit_Boost_Client_Security_Assessment_Report_v2_0.pdf). +## Verifying release artifacts + +All release binaries are signed using [Sigstore cosign](https://docs.sigstore.dev/cosign/overview/). You can verify that a binary was built by the official Commit-Boost CI pipeline from the tagged commit of any release. + +### Prerequisites + +Install cosign: [cosign installation guide](https://docs.sigstore.dev/cosign/system_config/installation/) + +### Verify a binary + +```bash +# Set the release version and your target architecture +# Architecture options: darwin_arm64, linux_arm64, linux_x86-64 +export VERSION=vX.Y.Z +export ARCH=linux_x86-64 + +# Download the binary tarball and its signature bundle +curl -sL -o "commit-boost-$VERSION-$ARCH.tar.gz" \ + "https://github.com/Commit-Boost/commit-boost-client/releases/download/$VERSION/commit-boost-$VERSION-$ARCH.tar.gz" +curl -sL -o "commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" \ + "https://github.com/Commit-Boost/commit-boost-client/releases/download/$VERSION/commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" + +# Verify the binary was signed by the official release pipeline +cosign verify-blob \ + "commit-boost-$VERSION-$ARCH.tar.gz" \ + --bundle "commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + --certificate-identity="https://github.com/Commit-Boost/commit-boost-client/.github/workflows/release.yml@refs/tags/$VERSION" +``` + +A successful verification prints `Verified OK`. If the binary was modified after being built by CI, verification will fail. + +The `.sigstore.json` bundle for each binary is attached to the release alongside the tarball itself. + ## Acknowledgements - [MEV boost](https://github.com/flashbots/mev-boost) - [Reth](https://github.com/paradigmxyz/reth) From 0bbb6b2fc745a9f700b613d334b415f4992aebb7 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 02:35:34 +0100 Subject: [PATCH 10/19] Create v2.1.0.yml (#42) --- .releases/v2.1.0.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.1.0.yml diff --git a/.releases/v2.1.0.yml b/.releases/v2.1.0.yml new file mode 100644 index 00000000..f71981a1 --- /dev/null +++ b/.releases/v2.1.0.yml @@ -0,0 +1,2 @@ +commit: 321dfbad87378155dc8d62bd067e86efba8f7f38 +reason: "Test" From b334bc9e8ad0e807b27f18c136eb434b67bbeebb Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 03:14:44 +0100 Subject: [PATCH 11/19] Update release (#43) * new release process * add linting / better readme * bump action versions for warnings + stricter semver checks * binary signing * - finalize-release downloads both the raw binaries AND the signed .sigstore.json bundles, and attaches everything to the draft release - fix chained ci cmds - dedup docker build cmds * remove extra sign-binaries --- .github/workflows/release.yml | 41 +++++------------------------------ 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9522a9b..3f229cac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -213,15 +213,9 @@ jobs: matrix: crate: [pbs, signer] runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 10 steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: true - - - name: Download binary archives + - name: Download binary artifacts uses: actions/download-artifact@v4 with: path: ./artifacts @@ -245,12 +239,10 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + - name: Sign all binaries with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + inputs: ./artifacts/**/*.tar.gz - name: Build and push Docker image uses: docker/build-push-action@v6 @@ -288,29 +280,6 @@ jobs: name: signed-${{ github.ref_name }} path: ./artifacts/**/* - # Signs all Linux binaries with Sigstore for provenance - sign-binaries: - needs: [build-binaries-linux] - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Download binary artifacts - uses: actions/download-artifact@v4 - with: - path: ./artifacts - pattern: "commit-boost-*linux*" - - - name: Sign all binaries with Sigstore - uses: sigstore/gh-action-sigstore-python@v3.0.0 - with: - inputs: ./artifacts/**/*.tar.gz - - - name: Upload signed artifacts - uses: actions/upload-artifact@v4 - with: - name: signed-${{ github.ref_name }} - path: ./artifacts/**/* - # Creates a draft release on GitHub with the binaries finalize-release: needs: From 93bfc4ba15d85258cbda2d91c29b11b0a663d9db Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 03:15:59 +0100 Subject: [PATCH 12/19] Create v2.1.1.yml (#44) --- .releases/v2.1.1.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.1.1.yml diff --git a/.releases/v2.1.1.yml b/.releases/v2.1.1.yml new file mode 100644 index 00000000..1b846bbc --- /dev/null +++ b/.releases/v2.1.1.yml @@ -0,0 +1,2 @@ +commit: b334bc9e8ad0e807b27f18c136eb434b67bbeebb +reason: "Test" From 3f6f34050d8b24dcc0f9443af6f79dd5c567009f Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 03:49:28 +0100 Subject: [PATCH 13/19] Update release (#45) * new release process * add linting / better readme * bump action versions for warnings + stricter semver checks * binary signing * - finalize-release downloads both the raw binaries AND the signed .sigstore.json bundles, and attaches everything to the draft release - fix chained ci cmds - dedup docker build cmds * remove extra sign-binaries From 11809fabd6ae360ba5e7efe696c638cc2bc8bc19 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 03:51:23 +0100 Subject: [PATCH 14/19] Create v2.1.2.yml (#46) --- .releases/v2.1.2.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.1.2.yml diff --git a/.releases/v2.1.2.yml b/.releases/v2.1.2.yml new file mode 100644 index 00000000..6ab0c34f --- /dev/null +++ b/.releases/v2.1.2.yml @@ -0,0 +1,2 @@ +commit: 3f6f34050d8b24dcc0f9443af6f79dd5c567009f +reason: "Test" From 9c43aae429a1488a30441616e03fbd9d1959091b Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 04:06:49 +0100 Subject: [PATCH 15/19] Update release (#47) * new release process * add linting / better readme * bump action versions for warnings + stricter semver checks * binary signing * - finalize-release downloads both the raw binaries AND the signed .sigstore.json bundles, and attaches everything to the draft release - fix chained ci cmds - dedup docker build cmds * remove extra sign-binaries * docker fixes From 2e8c38ac630519c83e927b046ba59d22a9b4d258 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 04:08:46 +0100 Subject: [PATCH 16/19] Create v2.1.3.yml (#48) --- .releases/v2.1.3.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.1.3.yml diff --git a/.releases/v2.1.3.yml b/.releases/v2.1.3.yml new file mode 100644 index 00000000..5236e52f --- /dev/null +++ b/.releases/v2.1.3.yml @@ -0,0 +1,2 @@ +commit: 9c43aae429a1488a30441616e03fbd9d1959091b +reason: "Test" From a6bb2503f6fbfdcbfa5da5c2239286c9977227cc Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 24 Apr 2026 21:00:30 -0700 Subject: [PATCH 17/19] Update release.yml --- .github/workflows/release.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f229cac..e4450523 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -213,9 +213,15 @@ jobs: matrix: crate: [pbs, signer] runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 45 steps: - - name: Download binary artifacts + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: true + + - name: Download binary archives uses: actions/download-artifact@v4 with: path: ./artifacts @@ -239,10 +245,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Sign all binaries with Sigstore - uses: sigstore/gh-action-sigstore-python@v3.0.0 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 with: - inputs: ./artifacts/**/*.tar.gz + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v6 From c1fd43c998ae185a8471e32d9b00c0116bd983c9 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Sat, 25 Apr 2026 05:01:30 +0100 Subject: [PATCH 18/19] Create v2.1.4.yml (#49) --- .releases/v2.1.4.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v2.1.4.yml diff --git a/.releases/v2.1.4.yml b/.releases/v2.1.4.yml new file mode 100644 index 00000000..2abce779 --- /dev/null +++ b/.releases/v2.1.4.yml @@ -0,0 +1,2 @@ +commit: a6bb2503f6fbfdcbfa5da5c2239286c9977227cc +reason: "Test" From 9a52a7e06799e2f2d3e5981974bc469a49758449 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 24 Apr 2026 21:13:47 -0700 Subject: [PATCH 19/19] attempt failed ci --- .releases/v0.1.4.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .releases/v0.1.4.yml diff --git a/.releases/v0.1.4.yml b/.releases/v0.1.4.yml new file mode 100644 index 00000000..2abce779 --- /dev/null +++ b/.releases/v0.1.4.yml @@ -0,0 +1,2 @@ +commit: a6bb2503f6fbfdcbfa5da5c2239286c9977227cc +reason: "Test"