Files
gstack/test/bun-version-shells.test.ts
Garry Tanandcapy-ai[bot] 75badf306c v1.91.234.0 feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98) (#3429)
* feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98)

Every Monday morning .github/workflows/weekly-release.yml tags the newest green
commit on main as v<VERSION>, publishes a GitHub Release from the CHANGELOG
headlines and fast-forwards refs/heads/stable to it (scripts/release-cut.ts;
workflow_dispatch with dry_run, and ref for a hotfix). Team-mode installs
follow stable through the new update_channel key (auto: stable when
team_mode, else main) in bin/gstack-session-update, bin/gstack-update-check,
bin/gstack-upgrade and /gstack-upgrade. Per-merge stamping and the merge
train are unchanged. docs/releases.md explains it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(weekly-release): check out the dispatched ref so a dry run exercises that branch's script

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(weekly-release): a PR touching the release tooling runs the dry run against main (read-only); the cut job checks out the dispatched ref

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(gstack-upgrade): no $1 in the rendered skill (Claude Code rewrites $N), trim Step 4 under the size budget, pin the new floor-check literal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: leave setup untouched (its channel note moves to README and docs/releases.md); the release workflow and script are free-only PR files

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: record the pr-profile free-only classification as non-behavioral (HARNESS_VERSION 6)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(releases): gate the cut on a main-backstop check run, finish half-done cuts, keep team installs on their channel

/ship review fixes (GSTA-98, PR #3429):

- The weekly release read the Main backstop gate run's conclusion, but
  scripts/train-backstop.ts exits 0 when main moved past the push before it
  was gated (superseded), so a never-gated tree read as green. The backstop
  workflow now publishes its verdict on the commit as the `main-backstop`
  check run from a code-free checks:write job (success for covered|gated,
  neutral for inert|superseded|deferred, failure for frozen|failed), fed by
  the new `--outputs` flag; release-cut.ts requires that check run and
  free-tests to succeed and holds a commit whose check runs are still going.
- A cut that pushed the tag and stable but failed to create the release can
  be finished: stable itself stays a candidate until its release exists, the
  notes compare against the release tag behind it, and a dry run with nothing
  green exits 0 with the skips named (the real cut still fails).
- weekly-release.yml: the cut job runs only from refs/heads/main, `ref` must
  be a full 40-hex SHA, and actions:read is dropped (no workflow-runs call).
- /gstack-upgrade: the destructive fallback requires the carried UPSTREAM_REF
  instead of defaulting to origin/main in a fresh shell; a team install past
  the release stops with AHEAD_OF_STABLE instead of FF_REFUSED and a reset
  backwards. gstack-update-channel.sh --fetch-ref keeps origin/stable on any
  fetch failure other than a missing release; dead gstack_update_channel_ref
  removed. gstack-update-check follows main before the first release instead
  of caching CHECK_FAILED; gstack-upgrade treats a failed ls-remote as a
  fetch failure, not an empty answer.
- Tests: release-cut (check-run fixture, partial cut, neutral/pending),
  weekly-release-workflow (new), evals-train-workflow and train-backstop
  (backstop-check job, --outputs), update-channel (no-release fallback,
  offline --fetch-ref), template pins.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(releases): the upgrade skill's ahead-of-stable exit works in depth-1 clones; update-check keeps its cache identity on the pre-release fallback

/ship second-pass review of 73e278d4 (GSTA-98, PR #3429):

- gstack-upgrade/SKILL.md.tmpl: README installs are depth-1 clones, where
  origin/stable never connects to HEAD, so the AHEAD_OF_STABLE shortcut now
  also fires when HEAD is not behind stable and stable's VERSION is not
  higher than the installed one (the order bin/gstack-upgrade uses). The
  fence prints the channel helper's warnings instead of discarding them, and
  a 'fetching origin/stable failed' line is a STOP. Prose trimmed to stay
  under the skill size budget. New behavioral test runs the Step 4 fence on
  a depth-1 install past the release (AHEAD_OF_STABLE, nothing moved) and on
  one behind it (goes on to the fast-forward).
- bin/gstack-update-check: the no-stable fallback keeps #stable in the
  cache identity (so the outcome replays within its TTL instead of two
  ls-remote calls per skill start), triggers only when ls-remote answered
  with no such branch (a failed ls-remote stays CHECK_FAILED on stable), and
  the branch-pinned URL derives from REMOTE_RAW_BASE so tests stay offline.
- scripts/release-cut.ts: a cancelled check run is red; the previous-release
  lookups check .ok before using git output. docs/releases.md says which
  pushes get no main-backstop check run at all.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 07:22:44 +00:00

115 lines
6.2 KiB
TypeScript

import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { checkInstallAdmission } from '../lib/install-verdict';
// #3195: the Bun floor check is sourced from whatever shell runs it; under zsh
// it must still judge (no word splitting), and a version it cannot read holds
// the update with BUN_VERSION_UNREADABLE instead of reading as "not below".
// One version table drives bash, zsh, bash --posix and the TypeScript twin.
const ROOT = path.resolve(import.meta.dir, '..');
const VERSIONS = path.join(ROOT, 'bin', 'gstack-bun-version.sh');
const hasZsh = spawnSync('zsh', ['-c', 'true'], { timeout: 10_000 }).status === 0;
const SHELLS: Array<[string, string[]]> = [['bash', ['bash']], ['bash --posix', ['bash', '--posix']], ['zsh', ['zsh']]];
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
/** Status against the floor 1.3.3 and the tested 1.4.2. */
const TABLE: Record<string, 'too-old' | 'untested' | 'ok' | 'malformed'> = {
'1.3.2': 'too-old',
'1.3.3-canary.1': 'too-old',
'1.3.3': 'untested',
'1.3.14': 'untested',
'1.4.2': 'ok',
'1.10.0': 'ok',
'01.04.02': 'ok',
'2.0.0': 'ok',
garbage: 'malformed',
};
function run(shell: string[], script: string, args: string[] = [], env: Record<string, string> = {}) {
return spawnSync(shell[0]!, [...shell.slice(1), '-c', script, '_', VERSIONS, ...args], {
encoding: 'utf8', timeout: 10_000, env: { ...process.env, ...env },
});
}
/** A git checkout whose HEAD carries this release's bin/ and VERSION, plus a `bun` that prints `version`. */
function install(version: string) {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bun-shells-')));
bases.push(base);
const repo = path.join(base, 'install'), stub = path.join(base, 'stub');
fs.mkdirSync(path.join(repo, 'bin'), { recursive: true });
fs.mkdirSync(stub);
for (const f of ['gstack-bun-version.sh', 'gstack-sanitize-echo.sh']) if (fs.existsSync(path.join(ROOT, 'bin', f))) fs.copyFileSync(path.join(ROOT, 'bin', f), path.join(repo, 'bin', f));
fs.writeFileSync(path.join(repo, 'VERSION'), '9.9.9.9\n');
const git = (...a: string[]) => expect(spawnSync('git', ['-C', repo, ...a], { encoding: 'utf8', timeout: 10_000,
env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } }).status).toBe(0);
git('init', '-q'); git('add', '-A'); git('commit', '-qm', 'release');
fs.writeFileSync(path.join(stub, 'bun'), `#!/bin/sh\nprintf '%s\\n' '${version.replace(/'/g, "'\\''")}'\n`, { mode: 0o755 });
return { repo, env: { PATH: `${stub}:${process.env.PATH}` }, bun: path.join(stub, 'bun') };
}
describe.skipIf(process.platform === 'win32')('Bun floor check in every shell (#3195)', () => {
for (const [label, shell] of SHELLS) {
test.skipIf(label === 'zsh' && !hasZsh)(`${label}: gstack_bun_status judges the version table`, () => {
const got = Object.fromEntries(Object.keys(TABLE).map(v => [v, run(shell, '. "$1"; gstack_bun_status "$2"', [v]).stdout.trim()]));
expect(got).toEqual(TABLE);
});
}
test.skipIf(!hasZsh)('zsh: Bun 1.3.2 is too old (the issue repro)', () => {
const r = run(['zsh'], '. "$1"; gstack_bun_status 1.3.2');
expect(r.stdout.trim()).toBe('too-old');
expect(r.stderr).not.toContain('integer expression expected');
});
test('a floor the comparison cannot parse is malformed, never ok', () => {
for (const [label, shell] of SHELLS) {
if (label === 'zsh' && !hasZsh) continue;
expect(run(shell, '. "$1"; gstack_bun_status 1.4.2 "$2"', ['1.x.3']).stdout.trim()).toBe('malformed');
}
});
test('the TypeScript twin below() agrees with the same table', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bun-twin-'));
bases.push(base);
const refused = (version: string, release: string) => {
fs.mkdirSync(path.join(base, release, 'bin'), { recursive: true });
fs.writeFileSync(path.join(base, release, 'bin', 'gstack-bun-version.sh'), `GSTACK_BUN_TESTED="${release}"\n`);
return checkInstallAdmission({ skill: 'review', gstackRoot: path.join(base, release), bunVersion: version,
env: { GSTACK_SESSION_KIND: 'unattended' } }).status === 'runtime';
};
const got = Object.fromEntries(Object.keys(TABLE).map(v => [v,
v === 'garbage' ? (refused(v, '1.3.3') ? 'malformed' : 'accepted')
: refused(v, '1.3.3') ? 'too-old' : refused(v, '1.4.2') ? 'untested' : 'ok']));
expect(got).toEqual(TABLE);
});
for (const [label, shell] of SHELLS) {
test.skipIf(label === 'zsh' && !hasZsh)(`${label}: the incoming hold holds below the floor and on an unreadable version`, () => {
const old = install('1.3.2');
const held = run(shell, '. "$1"; gstack_bun_incoming_hold "$2" HEAD; echo "rc=$?"', [old.repo], old.env);
expect(held.stdout).toContain(`bun-too-old: found Bun 1.3.2 at ${old.bun}; gstack 9.9.9.9 needs 1.3.3 or newer`);
expect(held.stdout).toContain('rc=0');
const bad = install("bun\x1b[2J\nstages=setup it's");
const unreadable = run(shell, '. "$1"; gstack_bun_incoming_hold "$2" HEAD; echo "rc=$?"', [bad.repo], bad.env);
expect(unreadable.stdout).toBe(`bun-version-unreadable: could not read the version of Bun at ${bad.bun} (bun --version printed 'bun[2J'); ` +
'gstack 9.9.9.9 needs 1.3.3 or newer (BUN_VERSION_UNREADABLE)\nrc=0\n');
const fine = install('1.4.2');
expect(run(shell, '. "$1"; gstack_bun_incoming_hold "$2" HEAD; echo "rc=$?"', [fine.repo], fine.env).stdout).toBe('rc=1\n');
});
}
test('/gstack-upgrade Step 4 runs the floor check through bash, whatever the tool shell is', () => {
const tmpl = fs.readFileSync(path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'), 'utf8');
expect(tmpl).toContain(`HOLD=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null)`);
expect(tmpl).not.toMatch(/HOLD=\$\(\. bin\/gstack-bun-version\.sh/);
expect(tmpl).toContain('BUN_VERSION_UNREADABLE');
});
});