Skip to content

Commit e4a79a1

Browse files
jclapisJasonVranek
andauthored
Add binary signing to GitHub releases (#433)
Co-authored-by: Jason Vranek <jasonvranek@gmail.com>
1 parent 87ab03e commit e4a79a1

3 files changed

Lines changed: 292 additions & 8 deletions

File tree

.github/workflows/release-gate.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: Release Gate
2+
3+
on:
4+
pull_request:
5+
types: [closed]
6+
branches: [main]
7+
8+
jobs:
9+
release-gate:
10+
name: Tag and update release branches
11+
runs-on: ubuntu-latest
12+
# Only run when a release/ branch is merged (not just closed)
13+
if: |
14+
github.event.pull_request.merged == true &&
15+
startsWith(github.event.pull_request.head.ref, 'release/v')
16+
17+
permissions:
18+
contents: write
19+
20+
steps:
21+
- uses: actions/create-github-app-token@v1
22+
id: app-token
23+
with:
24+
app-id: ${{ secrets.APP_ID }}
25+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
26+
27+
- uses: actions/checkout@v4
28+
with:
29+
# Full history required for version comparison against existing tags
30+
# and for the fast-forward push to stable/beta.
31+
fetch-depth: 0
32+
token: ${{ steps.app-token.outputs.token }}
33+
34+
- name: Extract and validate version
35+
id: version
36+
run: |
37+
BRANCH="${{ github.event.pull_request.head.ref }}"
38+
NEW_VERSION="${BRANCH#release/}"
39+
echo "new=${NEW_VERSION}" >> $GITHUB_OUTPUT
40+
41+
# Determine if this is an RC
42+
if echo "$NEW_VERSION" | grep -qE '\-rc[0-9]+$'; then
43+
echo "is_rc=true" >> $GITHUB_OUTPUT
44+
else
45+
echo "is_rc=false" >> $GITHUB_OUTPUT
46+
fi
47+
48+
- name: Validate version is strictly increasing
49+
run: |
50+
NEW_VERSION="${{ steps.version.outputs.new }}"
51+
52+
# Get the latest tag; if none exist yet, skip the comparison
53+
LATEST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -n1)
54+
if [ -z "$LATEST_TAG" ]; then
55+
echo "No existing tags found — skipping version comparison"
56+
exit 0
57+
fi
58+
59+
LATEST_VERSION="${LATEST_TAG#v}"
60+
61+
python3 - <<EOF
62+
import sys
63+
from packaging.version import Version
64+
65+
def normalize(v):
66+
# Convert vX.Y.Z-rcQ → X.Y.ZrcQ (PEP 440)
67+
return v.replace("-rc", "rc")
68+
69+
new = Version(normalize("$NEW_VERSION"))
70+
latest = Version(normalize("$LATEST_VERSION"))
71+
72+
print(f"Latest tag : {latest}")
73+
print(f"New version: {new}")
74+
75+
if new <= latest:
76+
print(f"\n❌ {new} is not strictly greater than current {latest}")
77+
sys.exit(1)
78+
79+
print(f"\n✅ Version order is valid")
80+
EOF
81+
82+
- name: Configure git
83+
run: |
84+
git config user.name "commit-boost-release-bot[bot]"
85+
git config user.email "commit-boost-release-bot[bot]@users.noreply.github.com"
86+
87+
- name: Create and push tag
88+
run: |
89+
VERSION="${{ steps.version.outputs.new }}"
90+
git tag "$VERSION" HEAD
91+
git push origin "$VERSION"
92+
# Branch fast-forwarding happens in release.yml after all artifacts
93+
# are successfully built. stable/beta are never touched if the build fails.

.github/workflows/release.yml

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
permissions:
99
contents: write
1010
packages: write
11+
id-token: write
1112

1213
jobs:
1314
# Builds the x64 and arm64 binary for Linux via the Docker builder
@@ -38,6 +39,9 @@ jobs:
3839
run: |
3940
echo "Releasing commit: $(git rev-parse HEAD)"
4041
42+
- name: Set lowercase owner
43+
run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
44+
4145
- name: Set up QEMU
4246
uses: docker/setup-qemu-action@v3
4347

@@ -57,8 +61,8 @@ jobs:
5761
context: .
5862
push: false
5963
platforms: linux/amd64,linux/arm64
60-
cache-from: type=registry,ref=ghcr.io/commit-boost/buildcache:${{ matrix.target-crate}}
61-
cache-to: type=registry,ref=ghcr.io/commit-boost/buildcache:${{ matrix.target-crate }},mode=max
64+
cache-from: type=registry,ref=ghcr.io/${{ env.OWNER }}/buildcache:${{ matrix.target-crate}}
65+
cache-to: type=registry,ref=ghcr.io/${{ env.OWNER }}/buildcache:${{ matrix.target-crate }},mode=max
6266
file: provisioning/build.Dockerfile
6367
outputs: type=local,dest=build
6468
build-args: |
@@ -150,6 +154,31 @@ jobs:
150154
path: |
151155
${{ matrix.name }}-${{ github.ref_name }}-darwin_${{ matrix.package-suffix }}.tar.gz
152156
157+
# Signs the binaries
158+
sign-binaries:
159+
needs:
160+
- build-binaries-linux
161+
- build-binaries-darwin
162+
runs-on: ubuntu-latest
163+
steps:
164+
- name: Download artifacts
165+
uses: actions/download-artifact@v4
166+
with:
167+
path: ./artifacts
168+
pattern: "commit-boost*"
169+
170+
- name: Sign binaries
171+
uses: sigstore/gh-action-sigstore-python@v3.2.0
172+
with:
173+
inputs: ./artifacts/**/*.tar.gz
174+
175+
- name: Upload signatures
176+
uses: actions/upload-artifact@v4
177+
with:
178+
name: signatures-${{ github.ref_name }}
179+
path: |
180+
./artifacts/**/*.sigstore.json
181+
153182
# Builds the PBS Docker image
154183
build-and-push-pbs-docker:
155184
needs: [build-binaries-linux]
@@ -176,6 +205,9 @@ jobs:
176205
tar -xzf ./artifacts/commit-boost-${{ github.ref_name }}-linux_arm64/commit-boost-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin
177206
mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_arm64/commit-boost
178207
208+
- name: Set lowercase owner
209+
run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
210+
179211
- name: Set up QEMU
180212
uses: docker/setup-qemu-action@v3
181213

@@ -198,8 +230,8 @@ jobs:
198230
build-args: |
199231
BINARIES_PATH=./artifacts/bin
200232
tags: |
201-
ghcr.io/commit-boost/pbs:${{ github.ref_name }}
202-
${{ !contains(github.ref_name, 'rc') && 'ghcr.io/commit-boost/pbs:latest' || '' }}
233+
ghcr.io/${{ env.OWNER }}/pbs:${{ github.ref_name }}
234+
${{ !contains(github.ref_name, 'rc') && format('ghcr.io/{0}/pbs:latest', env.OWNER) || '' }}
203235
file: provisioning/pbs.Dockerfile
204236

205237
# Builds the Signer Docker image
@@ -228,6 +260,9 @@ jobs:
228260
tar -xzf ./artifacts/commit-boost-${{ github.ref_name }}-linux_arm64/commit-boost-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin
229261
mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_arm64/commit-boost
230262
263+
- name: Set lowercase owner
264+
run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
265+
231266
- name: Set up QEMU
232267
uses: docker/setup-qemu-action@v3
233268

@@ -250,32 +285,110 @@ jobs:
250285
build-args: |
251286
BINARIES_PATH=./artifacts/bin
252287
tags: |
253-
ghcr.io/commit-boost/signer:${{ github.ref_name }}
254-
${{ !contains(github.ref_name, 'rc') && 'ghcr.io/commit-boost/signer:latest' || '' }}
288+
ghcr.io/${{ env.OWNER }}/signer:${{ github.ref_name }}
289+
${{ !contains(github.ref_name, 'rc') && format('ghcr.io/{0}/signer:latest', env.OWNER) || '' }}
255290
file: provisioning/signer.Dockerfile
256291

257292
# Creates a draft release on GitHub with the binaries
258293
finalize-release:
259294
needs:
260295
- build-binaries-linux
261296
- build-binaries-darwin
297+
- sign-binaries
262298
- build-and-push-pbs-docker
263299
- build-and-push-signer-docker
264300
runs-on: ubuntu-latest
265301
steps:
266-
- name: Download artifacts
302+
- name: Download binaries
267303
uses: actions/download-artifact@v4
268304
with:
269305
path: ./artifacts
270306
pattern: "commit-boost*"
271307

308+
- name: Download signatures
309+
uses: actions/download-artifact@v4
310+
with:
311+
path: ./artifacts
312+
pattern: "signatures-${{ github.ref_name }}*"
313+
272314
- name: Finalize Release
273315
uses: softprops/action-gh-release@v2
274316
with:
275317
files: ./artifacts/**/*
276318
draft: true
277-
prerelease: false
319+
prerelease: ${{ contains(github.ref_name, '-rc') }}
278320
tag_name: ${{ github.ref_name }}
279321
name: ${{ github.ref_name }}
280322
env:
281323
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
324+
325+
# Fast-forwards stable (full release) or beta (RC) to the new tag.
326+
# Runs after all artifacts are built and the draft release is created,
327+
# so stable/beta are never touched if any part of the pipeline fails.
328+
fast-forward-branch:
329+
needs:
330+
- finalize-release
331+
runs-on: ubuntu-latest
332+
steps:
333+
- uses: actions/create-github-app-token@v1
334+
id: app-token
335+
with:
336+
app-id: ${{ secrets.APP_ID }}
337+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
338+
339+
- uses: actions/checkout@v4
340+
with:
341+
fetch-depth: 0
342+
token: ${{ steps.app-token.outputs.token }}
343+
344+
- name: Configure git
345+
run: |
346+
git config user.name "commit-boost-release-bot[bot]"
347+
git config user.email "commit-boost-release-bot[bot]@users.noreply.github.com"
348+
349+
- name: Fast-forward beta branch (RC releases)
350+
if: contains(github.ref_name, '-rc')
351+
run: |
352+
git checkout beta
353+
git merge --ff-only "${{ github.ref_name }}"
354+
git push origin beta
355+
356+
- name: Fast-forward stable branch (full releases)
357+
if: "!contains(github.ref_name, '-rc')"
358+
run: |
359+
git checkout stable
360+
git merge --ff-only "${{ github.ref_name }}"
361+
git push origin stable
362+
363+
# Deletes the tag if any job in the release pipeline fails.
364+
# This keeps the tag and release artifacts in sync — a tag should only
365+
# exist if the full pipeline completed successfully.
366+
# stable/beta are never touched on failure since fast-forward-branch
367+
# only runs after finalize-release succeeds.
368+
#
369+
# Note: if finalize-release specifically fails, a draft release may already
370+
# exist on GitHub pointing at the now-deleted tag and will need manual cleanup.
371+
cleanup-on-failure:
372+
needs:
373+
- build-binaries-linux
374+
- build-binaries-darwin
375+
- sign-binaries
376+
- build-and-push-pbs-docker
377+
- build-and-push-signer-docker
378+
- finalize-release
379+
- fast-forward-branch
380+
runs-on: ubuntu-latest
381+
if: failure()
382+
steps:
383+
- uses: actions/create-github-app-token@v1
384+
id: app-token
385+
with:
386+
app-id: ${{ secrets.APP_ID }}
387+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
388+
389+
- uses: actions/checkout@v4
390+
with:
391+
token: ${{ steps.app-token.outputs.token }}
392+
393+
- name: Delete tag
394+
run: git push origin --delete ${{ github.ref_name }}

RELEASE.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Releasing a new version of Commit-Boost
2+
3+
## Process
4+
5+
1. Cut a release candidate (RC)
6+
2. Test the RC
7+
3. Collect signoffs
8+
4. Cut the full release
9+
10+
## How it works
11+
12+
Releases are fully automated once a release PR is merged into `main`. The branch name controls what CI does:
13+
14+
| Branch name | Result |
15+
| --- | --- |
16+
| `release/vX.Y.Z-rcQ` | Creates RC tag, fast-forwards `beta`, builds and signs artifacts |
17+
| `release/vX.Y.Z` | Creates release tag, fast-forwards `stable`, builds and signs artifacts |
18+
19+
No human pushes tags or updates `stable`/`beta` directly, the CI handles everything after the PR merges.
20+
21+
## Cutting a release candidate
22+
23+
1. Create a branch named `release/vX.Y.Z-rc1`. For the first RC of a new version, bump the version in `Cargo.toml` and run `cargo check` to update `Cargo.lock`. Always update `CHANGELOG.md`.
24+
2. Open a PR targeting `main`. Get two approvals and merge.
25+
3. CI creates the tag, fast-forwards `beta`, builds and signs binaries, Docker images, and creates a draft release on GitHub.
26+
4. Test the RC on testnets. For subsequent RCs (`-rc2`, etc.), open a new release PR with only a `CHANGELOG.md` update (`Cargo.toml` does not change between RCs).
27+
28+
## Cutting the full release
29+
30+
Once testing is complete and signoffs are collected:
31+
32+
1. Create a branch named `release/vX.Y.Z` and update `CHANGELOG.md` with final release notes.
33+
2. Open a PR targeting `main`. Get two approvals and merge.
34+
3. CI creates the tag, fast-forwards `stable`, builds and signs artifacts, and creates a draft release.
35+
4. Open the draft release on GitHub:
36+
- Click **Generate release notes** and add a plain-language summary at the top
37+
- Call out any breaking config changes explicitly
38+
- Insert the [binary verification boilerplate text](#verifying-release-artifacts)
39+
- Set as **latest release** (not pre-release)
40+
- Publish
41+
5. Update the community.
42+
43+
## If the pipeline fails
44+
45+
CI will automatically delete the tag if any build step fails. `stable` and `beta` are only updated after all artifacts are successfully built, they are never touched on a failed run. Fix the issue and open a new release PR.
46+
47+
## Verifying release artifacts
48+
49+
All binaries are signed using [Sigstore cosign](https://docs.sigstore.dev/about/overview/). You can verify any binary was built by the official Commit-Boost CI pipeline from this release's commit.
50+
51+
Install cosign: https://docs.sigstore.dev/cosign/system_config/installation/
52+
53+
```bash
54+
# Set the release version and your target architecture
55+
# Architecture options: darwin_arm64, linux_arm64, linux_x86-64
56+
export VERSION=vX.Y.Z
57+
export ARCH=linux_x86-64
58+
59+
# Download the binary tarball and its signature
60+
curl -L \
61+
-o "commit-boost-$VERSION-$ARCH.tar.gz" \
62+
"https://github.com/Commit-Boost/commit-boost-client/releases/download/$VERSION/commit-boost-$VERSION-$ARCH.tar.gz"
63+
64+
curl -L \
65+
-o "commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" \
66+
"https://github.com/Commit-Boost/commit-boost-client/releases/download/$VERSION/commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json"
67+
68+
# Verify the binary was signed by the official CI pipeline
69+
cosign verify-blob \
70+
"commit-boost-$VERSION-$ARCH.tar.gz" \
71+
--bundle "commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" \
72+
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
73+
--certificate-identity="https://github.com/Commit-Boost/commit-boost-client/.github/workflows/release.yml@refs/tags/$VERSION"
74+
```
75+
76+
A successful verification prints `Verified OK`. If the binary was modified after being built by CI, this command will fail.
77+
78+
The `.sigstore.json` bundle for each binary is attached to this release alongside the binary itself.

0 commit comments

Comments
 (0)