From db639ff5696f94a2fde56cfb79e5014fa0907a13 Mon Sep 17 00:00:00 2001 From: Zack Sheppard Date: Wed, 7 Oct 2026 21:08:37 +0100 Subject: [PATCH] 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 --- .github/workflows/release.yml | 90 ++++++++------------------------ README.md | 10 ++-- apps/desktop/src/main/updates.ts | 6 ++- scripts/version.mjs | 6 +-- 4 files changed, 31 insertions(+), 81 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 878ec2c..dd57bcf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 1c032a8..4c8a384 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/desktop/src/main/updates.ts b/apps/desktop/src/main/updates.ts index 21c8dc1..75d6806 100644 --- a/apps/desktop/src/main/updates.ts +++ b/apps/desktop/src/main/updates.ts @@ -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})`); } diff --git a/scripts/version.mjs b/scripts/version.mjs index 1bfaa66..725089c 100644 --- a/scripts/version.mjs +++ b/scripts/version.mjs @@ -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); }