Skip to content

Commit 9943e35

Browse files
ci(release): automate publishing with an approval gate (OpenZeppelin#657)
Signed-off-by: 0xisk <0xisk@proton.me> Co-authored-by: Andrew Fleming <fleming-andrew@protonmail.com>
1 parent 43a41be commit 9943e35

4 files changed

Lines changed: 259 additions & 66 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Create release on version bump
2+
3+
# When a version bump merges into main (alpha line) or a release/* branch
4+
# (rc/stable line), cut the matching GitHub Release + tag. That fires
5+
# release.yml to publish to npm (behind the release approval gate).
6+
#
7+
# Idempotent: a release is cut only if its tag has no release yet, so any
8+
# push that does not change the version is a no-op and retries are safe.
9+
on:
10+
push:
11+
branches:
12+
- main
13+
- "release/**"
14+
paths:
15+
- "contracts/package.json"
16+
17+
permissions:
18+
contents: write
19+
20+
# Serialize per branch so two overlapping pushes can't both pass the
21+
# "does this release exist yet?" guard and race to create the same tag.
22+
concurrency:
23+
group: create-release-${{ github.ref }}
24+
cancel-in-progress: false
25+
26+
jobs:
27+
create-release:
28+
runs-on: ubuntu-24.04
29+
steps:
30+
- name: Harden Runner
31+
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
32+
with:
33+
egress-policy: audit
34+
35+
# App token (not GITHUB_TOKEN): a release created by GITHUB_TOKEN does
36+
# not trigger release.yml, so the publish would never run.
37+
- name: Get github app token
38+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
39+
id: gh-app-token
40+
with:
41+
app-id: ${{ vars.GH_APP_ID }}
42+
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
43+
# Least privilege: only contents write (create the tag + release).
44+
permission-contents: write
45+
46+
- name: Checkout
47+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
48+
with:
49+
# No git writes here (release is cut via gh/app token), so don't persist creds.
50+
persist-credentials: false
51+
52+
- name: Read package version
53+
id: version
54+
run: |
55+
VERSION=$(node -p "require('./contracts/package.json').version")
56+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
57+
# A SemVer prerelease has a hyphen (e.g. -alpha.1, -rc.1); stable does not.
58+
if [[ "$VERSION" == *-* ]]; then
59+
echo "prerelease=true" >> "$GITHUB_OUTPUT"
60+
else
61+
echo "prerelease=false" >> "$GITHUB_OUTPUT"
62+
fi
63+
64+
- name: Create release if the tag does not exist
65+
env:
66+
GH_TOKEN: ${{ steps.gh-app-token.outputs.token }}
67+
VERSION: ${{ steps.version.outputs.version }}
68+
PRERELEASE: ${{ steps.version.outputs.prerelease }}
69+
TARGET: ${{ github.sha }}
70+
run: |
71+
TAG="v${VERSION}"
72+
73+
# Idempotent guard: never re-cut an existing release (safe retries,
74+
# no manual tag deletion). Any non-bump push lands here and exits.
75+
if gh release view "$TAG" >/dev/null 2>&1; then
76+
echo "Release $TAG already exists — nothing to do."
77+
exit 0
78+
fi
79+
80+
PRERELEASE_FLAG=""
81+
if [ "$PRERELEASE" = "true" ]; then
82+
PRERELEASE_FLAG="--prerelease"
83+
fi
84+
85+
echo "Creating release $TAG on $TARGET (prerelease=$PRERELEASE)"
86+
gh release create "$TAG" \
87+
--target "$TARGET" \
88+
--title "$TAG" \
89+
--generate-notes \
90+
$PRERELEASE_FLAG

.github/workflows/prepare-release.yml

Lines changed: 93 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1-
name: Update version on new release branch
1+
name: Prepare release (version bump PR)
22

3+
# Manually dispatched. Pick the branch to release from (main for alphas,
4+
# release/X.Y.x for rc/stable) and type the exact target version. Opens a
5+
# version-bump PR into that branch; merging it cuts the release (see
6+
# create-release.yml).
37
on:
4-
create:
8+
workflow_dispatch:
9+
inputs:
10+
version:
11+
description: "Target version (exact SemVer, e.g. 0.3.0-alpha.3)"
12+
required: true
13+
type: string
514

615
permissions:
716
contents: write
817
pull-requests: write
918

19+
# Serialize per target version so a double-dispatch can't race on the
20+
# bump-branch delete/recreate; different versions still prepare in parallel.
21+
concurrency:
22+
group: prepare-release-${{ inputs.version }}
23+
cancel-in-progress: false
24+
1025
jobs:
11-
update_version:
12-
if: github.ref_type == 'branch' && startsWith(github.ref, 'refs/heads/release-v')
26+
prepare_release:
1327
runs-on: ubuntu-24.04
1428

1529
steps:
@@ -24,11 +38,16 @@ jobs:
2438
with:
2539
app-id: ${{ vars.GH_APP_ID }}
2640
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
41+
# Least privilege: commit the bump (contents) and open the PR (pull-requests).
42+
permission-contents: write
43+
permission-pull-requests: write
2744

2845
- name: Checkout repository
2946
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
3047
with:
3148
token: ${{ steps.gh-app-token.outputs.token }}
49+
# Commit/PR go through the app token via API, not git creds on disk.
50+
persist-credentials: false
3251

3352
- name: Enable Corepack
3453
run: corepack enable
@@ -44,33 +63,55 @@ jobs:
4463
CURRENT_VERSION=$(node -p "require('./contracts/package.json').version")
4564
echo "CURRENT_VERSION=$CURRENT_VERSION" >> "$GITHUB_ENV"
4665
47-
- name: Extract new version number
48-
run: echo "NEW_VERSION=${GITHUB_REF#refs/heads/release-v}" >> "$GITHUB_ENV"
66+
- name: Set new version
67+
env:
68+
INPUT_VERSION: ${{ inputs.version }}
69+
run: echo "NEW_VERSION=$INPUT_VERSION" >> "$GITHUB_ENV"
4970

5071
- name: Validate new version
72+
env:
73+
BASE_BRANCH: ${{ github.ref_name }}
5174
run: |
52-
BRANCH="${GITHUB_REF#refs/heads/}"
53-
echo "Branch: $BRANCH"
75+
echo "Base branch: $BASE_BRANCH"
5476
echo "Current version: $CURRENT_VERSION"
5577
echo "New version: $NEW_VERSION"
5678
57-
# 1) Branch must match release-v<semver>
58-
if ! echo "$BRANCH" | grep -Eq '^release-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
59-
echo "Error: Branch '$BRANCH' must match 'release-v<semver>' (e.g., release-v1.2.3)." >&2
60-
exit 1
61-
fi
79+
# 1) NEW_VERSION must be valid semver
80+
node -e "const v=process.env.NEW_VERSION; const semver=/^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$/; if(!semver.test(v)){ console.error('Error: version is not valid semver:', v); process.exit(1); }"
6281
63-
# 2) NEW_VERSION must be valid semver
64-
node -e "const v=process.env.NEW_VERSION; const semver=/^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$/; if(!semver.test(v)){ console.error('Error: NEW_VERSION is not valid semver:', v); process.exit(1); }"
65-
if [ $? -ne 0 ]; then
66-
exit 1
67-
fi
68-
69-
# 3) NEW_VERSION must differ from CURRENT_VERSION
70-
if [ "$NEW_VERSION" = "$CURRENT_VERSION" ]; then
71-
echo "Error: NEW_VERSION equals CURRENT_VERSION ($CURRENT_VERSION). Nothing to release." >&2
72-
exit 1
73-
fi
82+
# 2) NEW_VERSION must be a forward bump (strictly greater than CURRENT_VERSION
83+
# by SemVer precedence) so a typo can't downgrade the published version.
84+
node -e '
85+
const cmp = (a, b) => {
86+
const parse = (v) => {
87+
const noBuild = v.split("+")[0];
88+
const [core, pre] = noBuild.split("-");
89+
const nums = core.split(".").map(Number);
90+
return { nums, pre: pre ? pre.split(".") : null };
91+
};
92+
const A = parse(a), B = parse(b);
93+
for (let i = 0; i < 3; i++) if (A.nums[i] !== B.nums[i]) return A.nums[i] - B.nums[i];
94+
if (!A.pre && !B.pre) return 0;
95+
if (!A.pre) return 1; // release outranks prerelease
96+
if (!B.pre) return -1;
97+
const n = Math.max(A.pre.length, B.pre.length);
98+
for (let i = 0; i < n; i++) {
99+
const x = A.pre[i], y = B.pre[i];
100+
if (x === undefined) return -1;
101+
if (y === undefined) return 1;
102+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
103+
if (xn && yn) { const d = Number(x) - Number(y); if (d !== 0) return d; }
104+
else if (xn !== yn) return xn ? -1 : 1; // numeric identifiers rank lower
105+
else if (x !== y) return x < y ? -1 : 1;
106+
}
107+
return 0;
108+
};
109+
const nw = process.env.NEW_VERSION, cur = process.env.CURRENT_VERSION;
110+
if (cmp(nw, cur) <= 0) {
111+
console.error(`Error: NEW_VERSION (${nw}) must be strictly greater than CURRENT_VERSION (${cur}).`);
112+
process.exit(1);
113+
}
114+
'
74115
75116
- name: Replace version in files
76117
env:
@@ -111,11 +152,37 @@ jobs:
111152
echo 'EOF'
112153
} >> "$GITHUB_OUTPUT"
113154
155+
- name: Create bump branch
156+
env:
157+
GH_TOKEN: ${{ steps.gh-app-token.outputs.token }}
158+
run: |
159+
BUMP_BRANCH="chore/bump-${NEW_VERSION}"
160+
echo "BUMP_BRANCH=$BUMP_BRANCH" >> "$GITHUB_ENV"
161+
BASE_SHA=$(git rev-parse HEAD)
162+
# Idempotent re-runs: drop a stale bump branch left by a failed prior run
163+
# (closing any open PR from it) before recreating it at the current base.
164+
gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${BUMP_BRANCH}" >/dev/null 2>&1 || true
165+
gh api "repos/${{ github.repository }}/git/refs" \
166+
-f ref="refs/heads/${BUMP_BRANCH}" \
167+
-f sha="${BASE_SHA}"
168+
114169
# Uses GitHub API to create signed commits (requires creating blobs per file)
115170
- name: Commit version bump
116171
uses: iarekylew00t/verified-bot-commit@5b4e8852dc472093935b8debcb81459bb79f7986 # v2.3.2
117172
with:
118-
message: Bump version to ${{ env.NEW_VERSION }}
173+
message: "chore(release): bump version to ${{ env.NEW_VERSION }}"
119174
token: ${{ steps.gh-app-token.outputs.token }}
120-
ref: ${{ github.ref_name }}
175+
ref: ${{ env.BUMP_BRANCH }}
121176
files: ${{ steps.changes.outputs.files }}
177+
178+
- name: Open pull request
179+
env:
180+
GH_TOKEN: ${{ steps.gh-app-token.outputs.token }}
181+
BASE_BRANCH: ${{ github.ref_name }}
182+
run: |
183+
BODY=$(printf 'Automated version bump to `%s`.\n\nMerging this PR into `%s` cuts `v%s` and publishes to npm after the release approval gate.' "$NEW_VERSION" "$BASE_BRANCH" "$NEW_VERSION")
184+
gh pr create \
185+
--base "$BASE_BRANCH" \
186+
--head "$BUMP_BRANCH" \
187+
--title "chore(release): bump version to $NEW_VERSION" \
188+
--body "$BODY"

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on:
77
jobs:
88
publish:
99
runs-on: ubuntu-24.04
10+
# Manual approval gate: the `release` Environment must have required
11+
# reviewers configured in repo Settings, else this pauses for no one.
12+
environment: release
1013
permissions:
1114
contents: read
1215
id-token: write
@@ -19,6 +22,9 @@ jobs:
1922

2023
- name: Checkout
2124
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
25+
with:
26+
# No git writes here (publish goes through the npm token), so don't persist creds.
27+
persist-credentials: false
2228

2329
- name: Setup Node.js
2430
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0

RELEASING.md

Lines changed: 70 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,72 @@
11
# Releasing
22

3-
(1) Checkout the branch to be released.
4-
This will usually be `main` except in the event of a hotfix.
5-
For hotfixes, checkout the release branch you want to fix.
6-
7-
(2) Create a new release branch.
8-
9-
```sh
10-
git checkout -b release-v0.2.0
11-
```
12-
13-
(3) Push and open a PR targeting `main` to carefully review the release changes.
14-
This will trigger a GitHub workflow that automatically bumps the version number throughout the project.
15-
16-
```sh
17-
git push origin release-v0.2.0
18-
```
19-
20-
(4) Once merged, pull the changes from the release branch.
21-
Then, create a tag on the release branch and push it to the main repository.
22-
Note that the version changes must be pulled *before* the tag is created;
23-
otherwise, the version validation check will fail in the release workflow.
24-
25-
```sh
26-
git pull
27-
git tag v0.2.0
28-
git push origin v0.2.0
29-
```
30-
31-
(5) After that, go to the repo's [releases page](https://github.com/OpenZeppelin/compact-contracts/releases/).
32-
[Create a new release](https://github.com/OpenZeppelin/compact-contracts/releases/new) with the new tag and the base branch as target (`main` except in the event of a hotfix).
33-
Make sure to write a detailed release description and a short changelog.
34-
Once published, this will trigger a workflow to upload the release tarball to npm.
35-
36-
(6) Finally, from the released tag,
37-
create and push a doc branch to deploy the corresponding version to the doc-site.
38-
39-
```sh
40-
git checkout -b docs-v0.2.0
41-
git push origin docs-v0.2.0
42-
```
3+
Releases are automated. You bump the version via a workflow, merge the bump PR,
4+
and the tag, GitHub Release, and npm publish happen on their own (behind a
5+
manual approval gate).
6+
7+
Notation: `X.Y.Z` is a concrete SemVer; `release/X.Y.x` is the audit branch for
8+
the `X.Y` line (the `x` is literal — it denotes the patch line, not a point);
9+
`N` is a prerelease counter.
10+
11+
## Branch model
12+
13+
- **`main`** — the alpha line; always exists.
14+
- **`release/X.Y.x`** — the `X.Y` patch line; cut from `main` when an audit
15+
starts, carries `X.Y.0-rc.N → X.Y.0` (and any later `X.Y.Z` hotfixes). Deleted
16+
after back-merge; recreate it from the `vX.Y.Z` tag if a later hotfix is needed.
17+
18+
There is no long-lived per-version branch: a version bump is just a PR into
19+
`main` or a `release/X.Y.x` branch.
20+
21+
## How it works
22+
23+
1. Run **Prepare release** (`prepare-release.yml`) via *Actions → Run workflow*:
24+
pick the branch, type the exact target version. It opens a `chore/bump-<version>`
25+
PR into that branch.
26+
2. Review and merge the bump PR.
27+
3. On merge, **Create release** (`create-release.yml`) cuts `v<version>` + a
28+
GitHub Release from that branch (`--prerelease` for any `-alpha`/`-rc`
29+
version).
30+
4. That fires **Publish** (`release.yml`), which pauses at the `release`
31+
approval gate. An authorized reviewer approves, then it publishes to npm.
32+
33+
npm dist-tags: stable → `latest`, `-alpha`/`-rc``beta`.
34+
35+
## Versioning
36+
37+
Standard SemVer pre-release identifiers, in sort order:
38+
`X.Y.0-alpha.N → … → X.Y.0-rc.N → … → X.Y.0`. Counters start at `.1` (no
39+
`-rc.0`). Content drives the minor (`X.Y`); the 3-week cadence drives only the
40+
alpha counter.
41+
42+
## Common flows
43+
44+
**Scheduled alpha (from `main`)**
45+
- Prepare release on `main`, version `X.Y.0-alpha.N` → merge → publishes `beta`.
46+
47+
**Audit starts (cut the release line)**
48+
- `git branch release/X.Y.x <audit-sha> && git push origin release/X.Y.x`
49+
(creating the branch cuts nothing — its version is already tagged).
50+
- Prepare release on `release/X.Y.x`, version `X.Y.0-rc.1` → merge → publishes `beta`.
51+
- rc fixes later: repeat with `X.Y.0-rc.2`, etc.
52+
53+
**Audit clears (stable + next alpha, same day)**
54+
- Merge audit fixes into `release/X.Y.x`.
55+
- Prepare release on `release/X.Y.x`, version `X.Y.0` → merge → publishes `latest`.
56+
- Prepare release on `main`, next minor's first alpha `X.(Y+1).0-alpha.1` → merge
57+
→ publishes `beta`.
58+
- Back-merge `release/X.Y.x``main` (keep `main`'s higher version on conflict),
59+
then delete `release/X.Y.x`.
60+
61+
## Retries
62+
63+
Re-running is safe: `create-release.yml` only cuts a release if the tag has no
64+
release yet, so no manual tag/release deletion is needed.
65+
66+
## One-time setup (repo admin)
67+
68+
- **Settings → Environments → `release`**: add required reviewers (this is what
69+
makes the approval gate real; without it the publish job pauses for no one).
70+
- **Variables/secrets**: `GH_APP_ID` (var) and `GH_APP_PRIVATE_KEY` (secret) must
71+
be set — both `prepare-release.yml` and `create-release.yml` need the app token
72+
(the latter so its release event chains to the publish workflow).

0 commit comments

Comments
 (0)