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 new file mode 100644 index 00000000..40892102 --- /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@v6 + with: + fetch-depth: 0 + + - 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@v6 + 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..e4450523 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,10 +8,53 @@ on: permissions: contents: write packages: write + 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; the Docker job consumes its output via matrix. + determine-latest: + needs: [check-ci-status] + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + value: ${{ steps.is_latest.outputs.value }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - 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: + needs: [check-ci-status] + timeout-minutes: 60 strategy: matrix: target: @@ -35,7 +78,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: true @@ -44,6 +87,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 +109,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 +131,8 @@ jobs: # Builds the arm64 binaries for Darwin, for all 3 crates, natively build-binaries-darwin: + needs: [check-ci-status] + timeout-minutes: 60 strategy: matrix: target: @@ -105,7 +153,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: true @@ -158,13 +206,17 @@ 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] + # 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: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: true @@ -179,10 +231,13 @@ 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 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -197,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: . @@ -206,76 +261,51 @@ 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' || '' }} - 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] + # Signs all Linux binaries with Sigstore for provenance + sign-binaries: + needs: [check-ci-status, build-binaries-linux] runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - name: Checkout code - uses: actions/checkout@v4 - 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-*" + pattern: "commit-boost-*linux*" - - 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 up QEMU - uses: docker/setup-qemu-action@v3 - - - 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 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/commit-boost/signer:${{ github.ref_name }} - ${{ !contains(github.ref_name, 'rc') && 'ghcr.io/commit-boost/signer:latest' || '' }} - file: provisioning/signer.Dockerfile + name: signed-${{ github.ref_name }} + path: ./artifacts/**/* # 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/README.md b/.github/workflows/release/README.md new file mode 100644 index 00000000..207a6b5b --- /dev/null +++ b/.github/workflows/release/README.md @@ -0,0 +1,207 @@ +# Release automation + +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. + +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. + +## How a release happens + +``` + ┌────────────────────────────────────────────┐ + │ 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 │ + └────────────────────────────────────────────┘ +``` + +## 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 ..." +``` + +`.releases/v1.0.0-rc1.yml`: +```yaml +commit: 0123456789abcdef0123456789abcdef01234567 +reason: "First release candidate of the 1.0 line" +``` + +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 + +``` +# macOS / Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# or via Homebrew +brew install uv +``` + +### Run the test suite + +uv handles Python and dependencies in one shot. From the repo root: + +``` +uv run --with pyyaml --with pytest pytest .github/workflows/release/test_release.py -v +``` + +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`. + +### Run a subcommand + +``` +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 +``` + +### 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) + +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 + +See [Pre-commit sanity check](#pre-commit-sanity-check) above for the canonical recipe — it covers most needs. + +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 (argparse, ~370 lines) +├── test_release.py # pytest suite (~540 lines, 61 tests) +└── README.md # This file +``` + +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 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 new file mode 100644 index 00000000..d8b6ea57 --- /dev/null +++ b/.github/workflows/release/release.py @@ -0,0 +1,467 @@ +#!/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 + + +# ── 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] + + +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( + 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 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) + 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) + + import yaml + 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 = find_added(args.base, args.head) + 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 = find_modified_deleted(args.base, args.head) + 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 + # 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) + # 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 = max(candidates, key=_semver_key) + print("true" if highest == tag else "false") + 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") + + added = find_added(base, head) + mods = find_modified_deleted(base, head) + + 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 + + _step(cmd_validate_filename, argparse.Namespace(basename=basename)) + commit, _ = validate_yaml_file(filepath) + _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}") + 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 = find_added(base, merge_sha) + 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)) + + +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. + + 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: + 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) + + 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) + + 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..16a3920e --- /dev/null +++ b/.github/workflows/release/test_release.py @@ -0,0 +1,603 @@ +"""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, + cmd_lint, +) + +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", + ] + + 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 ────────────────────────────────────────────────────────────────── + +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() + + +# ── 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 + + 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 new file mode 100644 index 00000000..018f112b --- /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@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + 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. 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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)