mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e2de37ed | ||
|
|
d21911cb5f | ||
|
|
036f90fcd8 |
@@ -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,35 @@ 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
|
||||
# env-bound, not inlined into the script: VERSION comes from master so
|
||||
# it isn't attacker-reachable today, but a `${{ }}` inside `run:` is
|
||||
# shell injection by construction if that ever changes.
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
v="$RELEASE_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
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.42.71.0] - 2026-08-01
|
||||
|
||||
**GBrain now publishes real releases. Every version bump from here on lands on the [Releases page](https://github.com/garrytan/gbrain/releases) with organized notes and downloadable binaries — and binary self-update finally works.**
|
||||
|
||||
Until now the repo had no releases at all: `gbrain check-update` could tell you a new version existed, but `gbrain self-upgrade` downloaded from an empty releases API and failed every time, and anyone trying to follow what shipped had to read raw commit history. That's what people have been (rightly) complaining about.
|
||||
|
||||
From this release forward, every version bump automatically:
|
||||
|
||||
- **Tags the commit** (`v0.42.71.0`) so versions are addressable in git.
|
||||
- **Publishes a GitHub Release** whose notes are that version's CHANGELOG entry — the same organized, user-facing writeup, not a commit dump.
|
||||
- **Attaches compiled binaries** for macOS (arm64) and Linux (x64), so `gbrain self-upgrade` and fresh binary installs work without a toolchain.
|
||||
|
||||
The pipeline is idempotent: a partial release (tag exists, assets incomplete) is repaired on the next run instead of wedging. It runs post-merge, so a flaky release build can never turn master red. Releases for today's two fix waves (v0.42.69.0 and v0.42.70.0) have been backfilled with their CHANGELOG notes so the Releases page tells the whole story of the day; binaries attach from v0.42.71.0 onward.
|
||||
|
||||
### To take advantage of v0.42.71.0
|
||||
|
||||
```bash
|
||||
gbrain check-update # now resolves against real releases
|
||||
gbrain self-upgrade # now actually downloads a binary
|
||||
```
|
||||
|
||||
Or browse https://github.com/garrytan/gbrain/releases for organized per-version notes.
|
||||
|
||||
### For contributors
|
||||
|
||||
`docs/RELEASING.md` gains the release-publication section; `scripts/changelog-entry.sh` extracts a version's CHANGELOG section (used for release notes — keep entries under the standard `## [X.Y.Z.W]` headers and they publish verbatim). The workflow keeps all actions SHA-pinned, tightens top-level permissions to `contents: read` with write scoped to the release job only, and env-binds all interpolations.
|
||||
|
||||
Contributed by @time-attack (#3573, closing #3521).
|
||||
|
||||
## [0.42.70.0] - 2026-08-01
|
||||
|
||||
**Community fix wave two: 18 contributed fixes. The headline: several things you asked gbrain to do were being quietly ignored — and now they aren't.**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.42.70.0",
|
||||
"version": "0.42.71.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.4",
|
||||
|
||||
Executable
+21
@@ -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"
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user