Build releases when they're published instead of cutting them from a workflow

Publishing a vX.Y.Z release on GitHub now builds that tag as version
X.Y.Z and attaches the installers to the release. The version-bump
commit to main is gone, so releases work with branch protection. The
update checker only announces a release once its installers are attached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zack SheppardandClaude Opus 5.5 committed 2026-10-07 21:08:37 +01:00
1 parent c2c7f9bf5b
commit db639ff569
4 files changed
+31 -81

No files matched your search

+21 -69
View File
@@ -1,19 +1,15 @@
name: Release
# Run from the Actions tab: "Release" → Run workflow (on main).
# Builds the current head of main as the given version (default: the version
# in apps/desktop/package.json), publishes it as the latest GitHub release with
# the installers attached, then bumps main to the next patch version.
# Create a release in GitHub (Releases → Draft a new release) with a new tag
# like v1.2.3 on main, write the notes, and publish it. This workflow then
# builds that tag as version 1.2.3 and attaches the installers to the release.
# Nothing is pushed to main, so it works with branch protection.
on:
workflow_dispatch:
inputs:
version:
description: "Version to release, e.g. 1.2.0. Leave empty to release the version in package.json."
type: string
default: ""
release:
types: [published]
concurrency:
group: release
group: release-${{ github.event.release.tag_name }}
cancel-in-progress: false
permissions:
@@ -21,39 +17,21 @@ permissions:
jobs:
prepare:
name: Pick version
name: Read version from tag
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
next: ${{ steps.v.outputs.next }}
sha: ${{ steps.v.outputs.sha }}
steps:
- name: Only from main
if: github.ref != 'refs/heads/main'
run: |
echo "::error::Releases are cut from main. Pick main in the branch dropdown."
exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- id: v
env:
INPUT_VERSION: ${{ inputs.version }}
TAG: ${{ github.event.release.tag_name }}
run: |
version="${INPUT_VERSION:-$(node scripts/version.mjs get)}"
version="${version#v}"
node scripts/version.mjs next "$version" > /dev/null # validates the format
if git rev-parse -q --verify "refs/tags/v$version" > /dev/null; then
echo "::error::v$version is already released. Pass a newer version."
if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::Release tags must look like v1.2.3 (got \"$TAG\")."
exit 1
fi
{
echo "version=$version"
echo "next=$(node scripts/version.mjs next "$version")"
echo "sha=$(git rev-parse HEAD)"
} >> "$GITHUB_OUTPUT"
echo "Releasing v$version from $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
echo "Building ${TAG} as version ${BASH_REMATCH[1]}" >> "$GITHUB_STEP_SUMMARY"
build:
name: Build
@@ -61,11 +39,11 @@ jobs:
uses: ./.github/workflows/build.yml
with:
version: ${{ needs.prepare.outputs.version }}
ref: ${{ needs.prepare.outputs.sha }}
ref: refs/tags/${{ github.event.release.tag_name }}
publish:
name: Publish GitHub release
needs: [prepare, build]
attach:
name: Attach installers to the release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
@@ -75,37 +53,11 @@ jobs:
pattern: frameloader-*
path: dist
merge-multiple: true
- name: Create release
- name: Upload
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
SHA: ${{ needs.prepare.outputs.sha }}
TAG: ${{ github.event.release.tag_name }}
run: |
ls -la dist
gh release create "v$VERSION" dist/* \
--repo "$GITHUB_REPOSITORY" \
--target "$SHA" \
--title "Frameloader $VERSION" \
--generate-notes \
--latest
bump:
name: Bump main to the next version
needs: [prepare, publish]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Commit next version
env:
NEXT: ${{ needs.prepare.outputs.next }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
node scripts/version.mjs set "$NEXT"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Start $NEXT after releasing $VERSION"
git push origin HEAD:main
# --clobber so re-running a failed release replaces partial uploads.
gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber
+5 -5
View File
@@ -68,15 +68,15 @@ Every push and pull request runs `.github/workflows/build.yml`: typecheck, tests
To ship a release:
1. Open **Actions → Release → Run workflow** on `main`.
2. Leave the version empty to release the version in `apps/desktop/package.json`, or type one (e.g. `0.2.0`).
3. The workflow builds `main` with that version, publishes the `vX.Y.Z` GitHub release with the installers as the latest release, then commits the next patch version to `main`.
1. On GitHub, go to **Releases → Draft a new release**.
2. Create a new tag named `vX.Y.Z` (for example `v0.1.0`) targeting `main`, write the notes (or use **Generate release notes**), and publish.
3. Publishing runs `.github/workflows/release.yml`, which builds that tag stamped as version `X.Y.Z` and attaches the installers to the release. It takes a few minutes. If it fails, re-run the workflow; uploads replace any partial ones.
The workflow pushes that version bump straight to `main`. If `main` gets branch protection, allow GitHub Actions to push or change the last job to open a pull request.
Nothing is pushed to `main`, so this works with branch protection. The version in `apps/desktop/package.json` is only what local and CI builds report; releases take their version from the tag.
Builds are not code-signed yet. On macOS the app is ad-hoc signed, so the first launch needs right-click → Open (or `xattr -dr com.apple.quarantine /Applications/Frameloader.app`). Windows SmartScreen will warn until the installer is signed.
Installed apps notice new releases on their own: they check GitHub's releases API and show a banner with a link. Nothing is downloaded or installed automatically, and Settings → Updates turns the check off.
Installed apps notice new releases on their own: they check GitHub's releases API and show a banner with a link once a release has its installers attached. Nothing is downloaded or installed automatically, and Settings → Updates turns the check off.
## Website hosting (Cloudflare Workers)
+4 -2
View File
@@ -48,9 +48,11 @@ class Updates extends EventEmitter {
} else if (!res.ok) {
throw new Error(`GitHub answered HTTP ${res.status}`);
} else {
const body = (await res.json()) as { tag_name?: string; html_url?: string; draft?: boolean; prerelease?: boolean };
const body = (await res.json()) as { tag_name?: string; html_url?: string; draft?: boolean; prerelease?: boolean; assets?: unknown[] };
const latest = (body.tag_name ?? "").replace(/^v/i, "");
const available = !!latest && !body.draft && !body.prerelease && compareVersions(latest, current) > 0;
// A release is announced only once its installers are attached; the build takes a few minutes after publishing.
const ready = Array.isArray(body.assets) && body.assets.length > 0;
const available = !!latest && ready && !body.draft && !body.prerelease && compareVersions(latest, current) > 0;
this.status = { current, latest, available, url: body.html_url, checkedAt: new Date().toISOString() };
if (available) activity.info(`Frameloader ${latest} is available (you have ${current})`);
}
+1 -5
View File
@@ -2,7 +2,6 @@
// The app's version lives in apps/desktop/package.json.
// node scripts/version.mjs get -> prints the current version
// node scripts/version.mjs set 1.2.3 -> sets it
// node scripts/version.mjs next 1.2.3 -> prints 1.2.4 (the version after a release)
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
@@ -27,10 +26,7 @@ if (cmd === "get") {
pkg.version = check(arg);
writeFileSync(PKG, JSON.stringify(pkg, null, 2) + "\n");
console.log(pkg.version);
} else if (cmd === "next") {
const [, a, b, c] = check(arg ?? pkg.version).match(SEMVER);
console.log(`${a}.${b}.${Number(c) + 1}`);
} else {
console.error("usage: version.mjs get | set X.Y.Z | next [X.Y.Z]");
console.error("usage: version.mjs get | set X.Y.Z");
process.exit(1);
}