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>
This commit is contained in:
Garry Tan
2026-08-01 19:55:28 +08:00
committed by Sina Matian
co-authored by Time Attakc
parent dcaea7fdbb
commit 48618bd4bf
5 changed files with 252 additions and 10 deletions
+71 -4
View File
@@ -1,14 +1,67 @@
name: Release
# Publishes a GitHub release for every VERSION bump that lands on master:
# tag + title `v<VERSION>`, 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<VERSION> 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
+33
View File
@@ -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<VERSION>` 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<VERSION>` 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
+21
View File
@@ -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 <version> [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 <version> [changelog-file]}"
file="${2:-CHANGELOG.md}"
# Exact-string prefix match on "## [<ver>]" — 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"
+9 -6
View File
@@ -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';
+118
View File
@@ -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/<name>/<name>
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);
});
});