mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
This commit is contained in:
committed by
Sina Matian
co-authored by
Time Attakc
parent
dcaea7fdbb
commit
48618bd4bf
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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