chore(release): move release cutting to a Cut Release workflow (#972)
Replaces the local `pnpm release:{rc,patch,minor,major}` scripts with a
single manually-dispatched GitHub Actions workflow (`release-cut.yml`)
that takes `kind` (rc|stable), an optional `ref`, and an optional explicit
`version`.
Why: cutting releases locally was too easy to get wrong — `npm version
prerelease` behaves differently depending on whether the current version
is already an rc, there was no guard against tagging a dirty tree or an
off-main branch, and the "bad commit just landed, release the previous
one" case had no first-class answer. The new workflow:
- Resolves the next version automatically from GitHub Releases (or takes
an explicit override).
- Refuses to cut a stable release whose version isn't strictly greater
than the latest published stable. This is the only invariant
electron-updater needs within the `latest` channel.
- Only fast-forwards `main` with the version-bump commit when the caller
released the tip of `main`. Off-main releases publish only the tag, so
main is never polluted by a one-off RC against a feature branch.
CONTRIBUTING.md now documents the new flow with the common scenarios
(normal release, bad-commit-revert-to-previous-SHA, one-off RC, explicit
minor/major). The scheduled RC cron in release-rc.yml is untouched.
This commit is contained in:
parent
3d8cdcd12c
commit
c4a6701d9f
|
|
@ -0,0 +1,227 @@
|
|||
name: Cut Release
|
||||
|
||||
# Why: single entry point for manually cutting a release (RC or stable) from
|
||||
# any ref. Replaces the old local `pnpm release:*` scripts so releases are
|
||||
# always reproducible from CI and can never be accidentally tagged against
|
||||
# an uncommitted or non-main working tree.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Resolve `ref` to a SHA.
|
||||
# 2. Read the latest stable release from GitHub.
|
||||
# 3. Compute the next version (auto or from `version` input).
|
||||
# 4. For stable releases, REFUSE if the new version is <= the latest
|
||||
# stable. This is the only guard electron-updater actually needs — it
|
||||
# compares semver within a channel, so a regressing "latest" is the
|
||||
# one thing that breaks auto-update for fresh installs.
|
||||
# 5. Write package.json, commit (detached), tag, push tag.
|
||||
# 6. If ref was the tip of origin/main, fast-forward main to include the
|
||||
# version-bump commit so developers see the right version locally.
|
||||
# 7. Invoke release.yml to build and publish artifacts.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
kind:
|
||||
description: Release kind
|
||||
required: true
|
||||
type: choice
|
||||
default: rc
|
||||
options:
|
||||
- rc
|
||||
- stable
|
||||
ref:
|
||||
description: Branch, tag, or SHA to release from (default main)
|
||||
required: false
|
||||
type: string
|
||||
default: main
|
||||
version:
|
||||
description: Explicit base version, e.g. 1.4.0. Leave blank to auto-compute.
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-cut
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cut:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout ref
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Resolve ref SHA
|
||||
id: resolve
|
||||
run: |
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Why: only push the version-bump commit back to main when the
|
||||
# caller is releasing the exact tip of main. For any older or
|
||||
# off-main ref we leave main alone and only publish the tag.
|
||||
git fetch origin main --quiet
|
||||
main_sha="$(git rev-parse origin/main)"
|
||||
if [[ "$sha" == "$main_sha" ]]; then
|
||||
echo "push_main=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push_main=false" >>"$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Compute next version
|
||||
id: version
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KIND: ${{ inputs.kind }}
|
||||
EXPLICIT: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Latest published stable release (excludes drafts + prereleases).
|
||||
latest_stable="$(gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--exclude-drafts \
|
||||
--exclude-pre-releases \
|
||||
--limit 1 \
|
||||
--json tagName \
|
||||
--jq '.[0].tagName // ""')"
|
||||
latest_stable="${latest_stable#v}"
|
||||
echo "Latest stable: ${latest_stable:-<none>}"
|
||||
|
||||
# Latest tag of any kind on the repo (including RCs).
|
||||
latest_tag="$(git tag --sort=-v:refname | head -n 1 || true)"
|
||||
latest_tag="${latest_tag#v}"
|
||||
echo "Latest tag: ${latest_tag:-<none>}"
|
||||
|
||||
# Strip any existing -rc.N suffix for base-version math.
|
||||
base_of() { echo "${1%%-rc.*}"; }
|
||||
|
||||
semver_gt() {
|
||||
# returns 0 if $1 > $2 by semver rules (ignoring prerelease)
|
||||
node -e '
|
||||
const a = process.argv[1].split(".").map(Number);
|
||||
const b = process.argv[2].split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((a[i]||0) > (b[i]||0)) process.exit(0);
|
||||
if ((a[i]||0) < (b[i]||0)) process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
' "$1" "$2"
|
||||
}
|
||||
|
||||
bump_patch() {
|
||||
node -e '
|
||||
const v = process.argv[1].split(".").map(Number);
|
||||
console.log(`${v[0]}.${v[1]}.${(v[2]||0)+1}`);
|
||||
' "$1"
|
||||
}
|
||||
|
||||
next_rc_in_series() {
|
||||
# Given a base version, find the highest existing -rc.N for it
|
||||
# and return base-rc.(N+1). If none exists, base-rc.0.
|
||||
local base="$1"
|
||||
local highest
|
||||
highest="$(git tag --list "v${base}-rc.*" \
|
||||
| sed -E "s/^v${base//./\\.}-rc\.//" \
|
||||
| sort -n \
|
||||
| tail -n 1 || true)"
|
||||
if [[ -z "$highest" ]]; then
|
||||
echo "${base}-rc.0"
|
||||
else
|
||||
echo "${base}-rc.$((highest + 1))"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -n "$EXPLICIT" ]]; then
|
||||
base="${EXPLICIT#v}"
|
||||
base="$(base_of "$base")"
|
||||
else
|
||||
# Auto-compute base version.
|
||||
if [[ -z "$latest_tag" ]]; then
|
||||
# Fresh repo — start at 0.1.0.
|
||||
base="0.1.0"
|
||||
elif [[ "$latest_tag" == *"-rc."* ]]; then
|
||||
# Already in an RC series; reuse its base version.
|
||||
base="$(base_of "$latest_tag")"
|
||||
else
|
||||
# Latest is stable; new release is a patch bump.
|
||||
base="$(bump_patch "$latest_tag")"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$KIND" == "rc" ]]; then
|
||||
new="$(next_rc_in_series "$base")"
|
||||
else
|
||||
new="$base"
|
||||
# Refuse stable regressions — this is the updater-safety gate.
|
||||
if [[ -n "$latest_stable" ]] && ! semver_gt "$new" "$latest_stable"; then
|
||||
echo "::error::Refusing to cut stable $new: not greater than latest stable $latest_stable." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Refuse tag collisions (possible if someone already tagged manually).
|
||||
if git rev-parse "v$new" >/dev/null 2>&1; then
|
||||
echo "::error::Tag v$new already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$new" >>"$GITHUB_OUTPUT"
|
||||
echo "Next version: $new"
|
||||
|
||||
- name: Bump package.json and tag
|
||||
id: tag
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why: use npm version --no-git-tag-version so we control the commit
|
||||
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
|
||||
# assumptions and any lifecycle scripts that would run on bump).
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
git add package.json
|
||||
git commit -m "release: v$VERSION"
|
||||
git tag -a "v$VERSION" -m "v$VERSION"
|
||||
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
|
||||
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push tag
|
||||
env:
|
||||
PUSH_MAIN: ${{ steps.resolve.outputs.push_main }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$PUSH_MAIN" == "true" ]]; then
|
||||
# Fast-forward main to include the version-bump commit.
|
||||
git push origin "HEAD:refs/heads/main"
|
||||
git push origin "$TAG"
|
||||
else
|
||||
# Off-main release — only the tag is published; main is untouched.
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
release:
|
||||
needs: cut
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
tag: ${{ needs.cut.outputs.tag }}
|
||||
secrets: inherit
|
||||
|
|
@ -61,3 +61,40 @@ If there is no visual change, say that explicitly in the PR description.
|
|||
## Release Process
|
||||
|
||||
Version bumps, tags, and releases are maintainer-managed. Do not include release version changes in a normal contribution unless a maintainer asks for them.
|
||||
|
||||
### Cutting a release (maintainers)
|
||||
|
||||
All releases are cut from the **Cut Release** GitHub Actions workflow. There is no local `pnpm release:*` script — running releases locally is too easy to get wrong (dirty tree, wrong branch, stale main).
|
||||
|
||||
**To cut a release:**
|
||||
|
||||
1. Open [Actions → Cut Release](../../actions/workflows/release-cut.yml).
|
||||
2. Click **Run workflow** and pick:
|
||||
- **kind**: `rc` for a release candidate, `stable` for a public release.
|
||||
- **ref**: the branch, tag, or SHA to build from. Defaults to `main`.
|
||||
- **version** *(optional)*: an explicit base version like `1.4.0`. Leave blank to auto-compute.
|
||||
3. Run it.
|
||||
|
||||
The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and kicks off the multi-platform build via `release.yml`.
|
||||
|
||||
**Version auto-compute rules (when `version` is blank):**
|
||||
|
||||
- `kind=rc` on top of a stable (e.g. last tag was `v1.3.14`) → `v1.3.15-rc.0`.
|
||||
- `kind=rc` on top of an existing RC series (e.g. last tag was `v1.3.15-rc.2`) → `v1.3.15-rc.3`.
|
||||
- `kind=stable` on top of an RC series (e.g. last tag was `v1.3.15-rc.3`) → `v1.3.15` (promotes the RC base to stable).
|
||||
- `kind=stable` on top of a stable → the next patch (e.g. `v1.3.14` → `v1.3.15`). Use the explicit `version` input for minor/major bumps.
|
||||
|
||||
**Safety guarantees:**
|
||||
|
||||
- Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs.
|
||||
- Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history.
|
||||
- When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped.
|
||||
|
||||
**Common scenarios:**
|
||||
|
||||
- **Normal release:** `kind=stable`, `ref=main`, `version=` blank.
|
||||
- **"A bad commit just landed on main, release the commit before it":** `kind=stable`, `ref=<good-sha>`, `version=` blank. `main` is left alone; the tag points at the good SHA. Fix forward on `main` afterward.
|
||||
- **One-off RC for a feature branch:** `kind=rc`, `ref=<branch-or-sha>`. Produces an RC tag that does not touch `main`.
|
||||
- **Minor or major bump:** `kind=stable`, `version=1.4.0` (or `2.0.0`).
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
|
|
|||
|
|
@ -36,11 +36,7 @@
|
|||
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
|
||||
"build:linux": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --linux",
|
||||
"test:e2e": "npx playwright test --config tests/playwright.config.ts --project electron-headless",
|
||||
"test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"release:rc": "npm version prerelease --preid=rc && git push --follow-tags",
|
||||
"release:patch": "npm version patch && git push --follow-tags",
|
||||
"release:minor": "npm version minor && git push --follow-tags",
|
||||
"release:major": "npm version major && git push --follow-tags"
|
||||
"test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue