From 48618bd4bf801cf9a81d87836daa2dd888c5eb54 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 1 Aug 2026 19:36:12 +0800 Subject: [PATCH] feat(ci): publish GitHub releases so binary self-update can work (#3521) (#3573) Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com> --- .github/workflows/release.yml | 75 +++++++++++++++++++-- docs/RELEASING.md | 33 ++++++++++ scripts/changelog-entry.sh | 21 ++++++ src/commands/check-update.ts | 15 +++-- test/release-workflow.test.ts | 118 ++++++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 10 deletions(-) create mode 100755 scripts/changelog-entry.sh create mode 100644 test/release-workflow.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a76445ba7..01b890c3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,67 @@ name: Release +# Publishes a GitHub release for every VERSION bump that lands on master: +# tag + title `v`, notes from that version's CHANGELOG.md entry, +# compiled binaries attached (#3521). +# +# Why every bump: `gbrain check-update` resolves the latest version from the +# VERSION file on master, but binary self-update +# (src/core/binary-self-update.ts) downloads assets from `releases/latest`. +# If releases lag VERSION, binary installs are told an upgrade exists that +# self-update cannot apply. Keeping releases/latest == VERSION closes that gap. +# +# Idempotent: the `version` job skips build+release when a release for +# v already exists WITH all expected assets. A half-published release +# (tag exists / assets incomplete) is repaired on the next run — softprops +# updates the existing release in place. Historical 3-segment tags are never +# touched; a new 4-segment VERSION always mints a new tag. +# +# The asset names are a contract with expectedAssetName() in +# src/core/binary-self-update.ts, pinned by test/release-workflow.test.ts. + on: push: - tags: ['v*'] + branches: [master] + paths: [VERSION] + workflow_dispatch: {} # manual first run / backfill of the current VERSION permissions: - contents: write + contents: read + +concurrency: + group: release + cancel-in-progress: false jobs: + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + exists: ${{ steps.v.outputs.exists }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - id: v + name: Read VERSION and check for an existing complete release + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(tr -d '[:space:]' < VERSION)" + echo "version=$version" >> "$GITHUB_OUTPUT" + # Complete = release exists AND carries every asset the self-updater + # can request. A partial release must NOT short-circuit, so a re-run + # can repair it. + assets="$(gh release view "v$version" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '[.assets[].name] | sort | join(",")' 2>/dev/null || true)" + if [ "$assets" = "gbrain-darwin-arm64,gbrain-linux-x64" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Release v$version already published with all assets — nothing to do." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + build: + needs: version + if: needs.version.outputs.exists == 'false' strategy: matrix: include: @@ -44,16 +97,30 @@ jobs: path: bin/${{ matrix.artifact }} release: - needs: build + needs: [version, build] + if: needs.version.outputs.exists == 'false' runs-on: ubuntu-latest + permissions: + contents: write # create the tag + release (scoped to this job only) steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: artifacts + - name: Extract CHANGELOG entry for release notes + run: | + v="${{ needs.version.outputs.version }}" + if ! bash scripts/changelog-entry.sh "$v" > /tmp/release-notes.md || ! [ -s /tmp/release-notes.md ]; then + echo "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/master/CHANGELOG.md) for v$v." > /tmp/release-notes.md + fi - name: Create release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: + tag_name: v${{ needs.version.outputs.version }} + name: v${{ needs.version.outputs.version }} + target_commitish: ${{ github.sha }} + body_path: /tmp/release-notes.md + fail_on_unmatched_files: true files: | artifacts/gbrain-darwin-arm64/gbrain-darwin-arm64 artifacts/gbrain-linux-x64/gbrain-linux-x64 - generate_release_notes: true diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3cff19ddc..2168f47ad 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -362,6 +362,39 @@ done If any SHA differs from what's in the workflow files, update the pin and version comment. +## GitHub releases (binary assets + self-update) — #3521 + +`.github/workflows/release.yml` publishes a GitHub release automatically for +**every VERSION bump that lands on master** (trigger: push to master touching +`VERSION`, plus `workflow_dispatch` for a manual first run or repair). No +manual tag push is part of the ship flow — the workflow reads `VERSION` (the +single source of truth), mints tag `v` at the pushed commit, titles +the release the same, uses that version's `CHANGELOG.md` entry as the notes +(`scripts/changelog-entry.sh`; falls back to a CHANGELOG link if the entry is +missing), and attaches the compiled binaries. + +Why every bump, not selective: `gbrain check-update` resolves the latest +version from `VERSION` on master, while binary self-update +(`src/core/binary-self-update.ts`) downloads assets from `releases/latest`. +Any release that lags `VERSION` tells binary installs an upgrade exists that +self-update cannot apply. `releases/latest` must track `VERSION`. + +Invariants: + +- **Asset names are a contract.** The build matrix's `artifact:` names must + equal what `expectedAssetName()` in `src/core/binary-self-update.ts` + returns (`gbrain-darwin-arm64`, `gbrain-linux-x64` today). Adding a + platform means updating BOTH plus the version job's completeness check; + `test/release-workflow.test.ts` pins all of it. +- **Idempotent + self-repairing.** The version job skips when a release for + `v` already exists with all expected assets; a partial release + (tag but no release, or missing assets) is completed on re-run. Racing + master pushes queue via the `release` concurrency group — a skipped + intermediate version is fine, latest is what matters. +- **Historical tags are never rewritten.** Old 3-segment versions keep their + history; every new 4-segment `VERSION` mints a fresh tag. +- **Permissions stay scoped.** `contents: write` lives on the release job + only; everything else runs read-only. ## PR descriptions cover the whole branch diff --git a/scripts/changelog-entry.sh b/scripts/changelog-entry.sh new file mode 100755 index 000000000..4f407b8e9 --- /dev/null +++ b/scripts/changelog-entry.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Print the CHANGELOG.md body for one version (Keep-a-Changelog format), +# without its `## [X.Y.Z.W] - date` header line. Used by +# .github/workflows/release.yml as the GitHub release notes; tested by +# test/release-workflow.test.ts. +# +# Usage: changelog-entry.sh [changelog-file] +# Exits 1 when the version has no entry (caller falls back to a link stub). +set -euo pipefail + +ver="${1:?usage: changelog-entry.sh [changelog-file]}" +file="${2:-CHANGELOG.md}" + +# Exact-string prefix match on "## []" — no regex, so dots in the +# version can't glob and a 3-segment version can't match a 4-segment header. +awk -v ver="$ver" ' + index($0, "## [" ver "]") == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + END { exit found ? 0 : 1 } +' "$file" diff --git a/src/commands/check-update.ts b/src/commands/check-update.ts index a93a5ff37..c320b4b55 100644 --- a/src/commands/check-update.ts +++ b/src/commands/check-update.ts @@ -45,12 +45,15 @@ function upgradeCommandForMethod(method: string): string { } } -/** Where the latest version is resolved from. gbrain publishes NO GitHub - * releases (the `releases/latest` API is a permanent 404), so the release - * train's source of truth is the `VERSION` file on master — same trusted host - * `fetchChangelog` already uses. An npm fallback was rejected: the `gbrain` - * package on npm is an unrelated GPU library (#505), so it would produce false - * upgrade prompts pointing at a stranger's package. */ +/** Where the latest version is resolved from. The release train's source of + * truth is the `VERSION` file on master — same trusted host `fetchChangelog` + * already uses. GitHub releases are published from it per VERSION bump + * (`.github/workflows/release.yml`, #3521) and carry the binary assets, but + * this check deliberately does NOT read `releases/latest`: it was a permanent + * 404 before releases existed (#3520) and can still lag master. An npm + * fallback was rejected: the `gbrain` package on npm is an unrelated GPU + * library (#505), so it would produce false upgrade prompts pointing at a + * stranger's package. */ const VERSION_SOURCE_URL = 'https://raw.githubusercontent.com/garrytan/gbrain/master/VERSION'; const RELEASE_NOTES_URL = 'https://github.com/garrytan/gbrain/blob/master/CHANGELOG.md'; diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts new file mode 100644 index 000000000..216d03c50 --- /dev/null +++ b/test/release-workflow.test.ts @@ -0,0 +1,118 @@ +/** + * Contract pin between .github/workflows/release.yml and + * src/core/binary-self-update.ts (#3521). + * + * Binary installs download upgrade assets from `releases/latest` by the exact + * names expectedAssetName() returns. If the workflow's build matrix or the + * release `files:` list drifts from those names, self-update silently degrades + * to notify-only (`no_asset`) for everyone — this test is the guard. + */ +import { describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expectedAssetName } from '../src/core/binary-self-update.ts'; + +const ROOT = join(import.meta.dir, '..'); +const WORKFLOW = readFileSync(join(ROOT, '.github/workflows/release.yml'), 'utf8'); + +/** Every platform/arch the self-updater can request an asset for. */ +const EXPECTED_ASSETS = ( + [ + ['darwin', 'arm64'], + ['linux', 'x64'], + ] as const +).map(([p, a]) => expectedAssetName(p, a) as string); + +describe('release.yml ↔ binary-self-update asset contract', () => { + test('workflow build matrix produces exactly the assets the updater requests', () => { + const artifacts = [...WORKFLOW.matchAll(/artifact:\s*(\S+)/g)].map((m) => m[1]).sort(); + expect(artifacts).toEqual([...EXPECTED_ASSETS].sort()); + }); + + test('every expected asset is attached to the release', () => { + for (const name of EXPECTED_ASSETS) { + // download-artifact unpacks to artifacts// + expect(WORKFLOW).toContain(`artifacts/${name}/${name}`); + } + }); + + test('idempotency completeness check names every expected asset', () => { + // The version job only skips when the existing release carries ALL assets; + // its sorted-join comparison string must stay in sync with the matrix. + expect(WORKFLOW).toContain([...EXPECTED_ASSETS].sort().join(',')); + }); + + test('release tag derives from the VERSION file, v-prefixed', () => { + expect(WORKFLOW).toContain('< VERSION'); + expect(WORKFLOW).toMatch(/tag_name: v\$\{\{ needs\.version\.outputs\.version \}\}/); + }); + + test('missing binaries fail the release instead of publishing assetless', () => { + expect(WORKFLOW).toContain('fail_on_unmatched_files: true'); + }); + + test('contents:write is scoped to the release job, not the whole workflow', () => { + const topLevel = WORKFLOW.slice(0, WORKFLOW.indexOf('jobs:')); + expect(topLevel).toContain('contents: read'); + expect(topLevel).not.toContain('contents: write'); + }); +}); + +describe('scripts/changelog-entry.sh', () => { + const FIXTURE = `# Changelog + +## [0.42.67.0] - 2026-07-28 + +Release summary line. + +### Fixed +- top entry fix + +## [0.42.6] - 2026-07-27 + +### Added +- historical 3-segment entry +`; + + function run(version: string): { out: string; code: number } { + const dir = mkdtempSync(join(tmpdir(), 'gbrain-chlog-')); + const file = join(dir, 'CHANGELOG.md'); + writeFileSync(file, FIXTURE); + try { + const out = execFileSync('bash', [join(ROOT, 'scripts/changelog-entry.sh'), version, file], { + encoding: 'utf-8', + }); + return { out, code: 0 }; + } catch (e: any) { + return { out: String(e.stdout ?? ''), code: e.status ?? 1 }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + test('extracts exactly the requested entry, header excluded', () => { + const { out, code } = run('0.42.67.0'); + expect(code).toBe(0); + expect(out).toContain('top entry fix'); + expect(out).not.toContain('## [0.42.67.0]'); + expect(out).not.toContain('historical 3-segment entry'); + }); + + test('extracts a non-top (historical 3-segment) entry', () => { + const { out, code } = run('0.42.6'); + expect(code).toBe(0); + expect(out).toContain('historical 3-segment entry'); + expect(out).not.toContain('top entry fix'); + }); + + test('exits non-zero for a version with no entry', () => { + expect(run('9.9.9.9').code).not.toBe(0); + }); + + test('a version that is a string prefix of another does not false-match', () => { + // "0.42.67" is a prefix of "0.42.67.0" but has no entry of its own. + expect(run('0.42.67').code).not.toBe(0); + }); +});