From 08301328e91208fb1c399c997eed3950700f505f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 09:09:15 -0700 Subject: [PATCH] v1.91.214.0 fix(brain-sync): --publish-removals cleans up its temp list and lock without 'list: unbound variable' (GSTA-87) (#3405) * fix(brain-sync): --publish-removals trap expands its temp paths at install, so cleanup and lock release run after the function returns (GSTA-87) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.214.0 stamp 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> --- CHANGELOG.md | 10 ++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-brain-sync | 2 +- package.json | 2 +- test/brain-sync-removals.test.ts | 22 ++++++++++++++++++++-- 6 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0482acc..0bc6ef79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.214.0] - 2026-10-25 + +**`gstack-brain-sync --publish-removals --yes` no longer prints `list: unbound variable` when it finishes, and it now removes its temp list and releases the sync lock.** The command's EXIT trap named the function's local `$list`. When a publish succeeded, the function returned before the script exited, so the trap ran after `list` had gone out of scope. Under `set -u` it stopped at the unbound variable and never reached the cleanup, which left `brain-sync-removals.*` in `$TMPDIR` and `.brain-sync.lock.d` in `~/.gstack` until a later run cleared the lock as stale (found by GSTA-44). + +### Fixed +- **`bin/gstack-brain-sync`**: `--publish-removals` writes the temp file paths into its EXIT/INT/TERM trap when it installs the trap (quoted with `printf %q`), so the cleanup and lock release work however the function ends. The drain's trap in `--once` also names locals, but every path in that function ends with `exit` while they are still in scope, so it's unchanged. The other trap sites (the drain's lock-only trap and `gstack-brain-restore`'s, which uses a global) don't have the bug. + +### Tests +- `test/brain-sync-removals.test.ts`: a new case runs `--publish-removals`, then `--publish-removals --yes` twice (publishing, then nothing to publish) with a private `TMPDIR`. After each run, stderr has no `unbound variable`, the exit status is 0, `TMPDIR` is empty and the lock directory is gone. It failed before the fix. + ## [1.91.213.0] - 2026-10-25 **The CI image and the Ubicloud free-suite VMs move off Node.js 22.20.0 to 22.23.4, and both now check the Node archive's SHA-256 before unpacking it (#3354, port of #3355 by @michael-sumner).** 22.20.0 is affected by the October 21, 2026 Node.js security releases, including the Brotli decoder out-of-bounds write (CVE-2026-111497, high) and the TLS hostname bypass for leading-zero IPv4 hosts (CVE-2026-106069, high). 22.23.4 is the patched 22.x release. #3355 bumped `.github/docker/Dockerfile.ci`, but `scripts/ubicloud/setup-free-suite.sh` pins Node separately and would have kept installing 22.20.0 on every remote free-suite run. diff --git a/VERSION b/VERSION index 5e7f1cab2..d3a2d850d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.213.0 +1.91.214.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 9a23cd137..6042037f3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.213.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.214.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 883e717e4..50f377b0b 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -1068,7 +1068,7 @@ subcmd_publish_removals() { sync_active || { echo "BRAIN_SYNC: artifacts sync is not active; nothing to publish" >&2; exit 1; } gstack_brain_lock_acquire "$GSTACK_HOME" try || { echo "BRAIN_SYNC: another sync is running; try again" >&2; exit 1; } list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || exit 1 - trap 'rm -f "$list" "$list.gone"; gstack_brain_lock_release' EXIT INT TERM + trap "rm -f $(printf '%q ' "$list" "$list.gone"); gstack_brain_lock_release" EXIT INT TERM removal_candidates "$list" || { echo "BRAIN_SYNC: could not list removed files" >&2; exit 1; } n=$(grep -c . "$list" 2>/dev/null || true) if [ "$n" -eq 0 ]; then diff --git a/package.json b/package.json index 345d9566f..045b6c41d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.213", + "version": "1.91.214", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/brain-sync-removals.test.ts b/test/brain-sync-removals.test.ts index 66b457375..405de494d 100644 --- a/test/brain-sync-removals.test.ts +++ b/test/brain-sync-removals.test.ts @@ -16,9 +16,9 @@ const ROOT = path.resolve(import.meta.dir, '..'); let home: string; let remote: string; -function run(argv: string[]) { +function run(argv: string[], extraEnv: Record = {}) { const r = spawnSync(path.join(ROOT, 'bin', argv[0]), argv.slice(1), { - env: { ...process.env, HOME: home, GSTACK_HOME: home }, + env: { ...process.env, HOME: home, GSTACK_HOME: home, ...extraEnv }, encoding: 'utf-8', timeout: 60_000, }); @@ -145,6 +145,24 @@ describe('artifacts_sync_removals', () => { expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).stdout).toContain('no removals to publish'); }); + test('--publish-removals cleans up its temp files and lock on every exit path', () => { + syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']); + fs.rmSync(path.join(home, 'projects/p/designs/a.md')); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-tmp-')); + try { + for (const argv of [['--publish-removals'], ['--publish-removals', '--yes'], ['--publish-removals', '--yes']]) { + const r = run(['gstack-brain-sync', ...argv], { TMPDIR: tmp }); + expect(r.stderr).not.toContain('unbound variable'); + expect(r.status).toBe(0); + expect(fs.readdirSync(tmp)).toEqual([]); + expect(fs.existsSync(path.join(home, '.brain-sync.lock.d'))).toBe(false); + } + expect(remoteFiles()).toEqual(['projects/p/designs/b.md']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + test('config rejects values other than on and off', () => { const r = run(['gstack-config', 'set', 'artifacts_sync_removals', 'yes']); expect(r.stderr).toContain("artifacts_sync_removals 'yes' not recognized");