Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/release-gate.yml
Original file line number Diff line number Diff line change
@@ -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
51 changes: 43 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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: |
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/release/README.md
Original file line number Diff line number Diff line change
@@ -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 <tag>`

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 <basename>` | Strict semver regex check (no leading zeros, `-rcN` suffix allowed) |
| `validate-yaml <path>` | Parse and schema-check a release-request YAML |
| `find-added --base <sha> --head <sha>` | List `.releases/*.yml` files added in a diff range |
| `check-modifications --base <sha> --head <sha>` | Reject modifications/deletions of existing YAMLs |
| `check-commit-exists <sha>` | Verify commit exists via GitHub API |
| `check-tag-free <tag>` | Verify tag doesn't already exist |
| `check-signatures <commit>` | Confirm all commits from nearest tag ancestor to the release commit are signed |
| `create-tag <tag> <commit>` | Create signed tag via GitHub API (`POST /git/tags` + `POST /git/refs`) |
| `is-latest <tag>` | 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.
Loading
Loading