From 678276396f846cc15ed8df787e23b3adf35198b4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 6 Nov 2026 12:28:28 -0800 Subject: [PATCH] test: keep update-channel tests off the real state root; guard it (GST-1659) (#4094) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../capy-gst-1659-state-root-isolation.md | 1 + scripts/free-test-durations.json | 1 + test/helpers/install-fixture.ts | 6 +- test/helpers/run-bin.ts | 12 +-- test/helpers/state-root-isolation.ts | 21 +++++ test/state-root-isolation.test.ts | 80 +++++++++++++++++++ test/update-channel.test.ts | 43 +++++----- 7 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 changelog.d/capy-gst-1659-state-root-isolation.md create mode 100644 test/helpers/state-root-isolation.ts create mode 100644 test/state-root-isolation.test.ts diff --git a/changelog.d/capy-gst-1659-state-root-isolation.md b/changelog.d/capy-gst-1659-state-root-isolation.md new file mode 100644 index 000000000..ed1cbd073 --- /dev/null +++ b/changelog.d/capy-gst-1659-state-root-isolation.md @@ -0,0 +1 @@ +- **The update-channel tests can no longer write your real gstack config.** `test/update-channel.test.ts`'s `gstack-config` case wrote `update_channel` into whatever `GSTACK_STATE_ROOT` the shell exported whenever the run skipped this repository's `bunfig.toml` preload (a run from another directory, or `bun --config=/dev/null test`), which could flip a developer's or crew machine's update channel. The `runBin` helper's `gstackHome` option now points all three state-root variables at the test's directory, every spawn in that file starts from an environment with the state-root chain removed and HOME in a temp dir, and a new guard (`test/state-root-isolation.test.ts`) runs both update-channel test files with `GSTACK_STATE_ROOT`, `GSTACK_HOME`, `GSTACK_STATE_DIR`, a live `CLAUDE_PLUGIN_DATA` and HOME exported to sentinel directories and fails if any of them changes. The install fixture also stops treating a worktree's `node_modules` symlink as source, so `update-channel-setup.test.ts` and the other real-setup tests run from a worktree that shares its main checkout's dependencies. (GST-1659) diff --git a/scripts/free-test-durations.json b/scripts/free-test-durations.json index 63d1cc68a..ec7991f97 100644 --- a/scripts/free-test-durations.json +++ b/scripts/free-test-durations.json @@ -1373,6 +1373,7 @@ "test/stamp-templates.test.ts": 457, "test/state-matrix.test.ts": 1040, "test/state-root-config-merge.test.ts": 1014, + "test/state-root-isolation.test.ts": 43000, "test/state-root-one-variable.test.ts": 4040, "test/state-root-parity.test.ts": 136, "test/state-root-ratchet.test.ts": 290, diff --git a/test/helpers/install-fixture.ts b/test/helpers/install-fixture.ts index 2c08c54b5..2018db557 100644 --- a/test/helpers/install-fixture.ts +++ b/test/helpers/install-fixture.ts @@ -4,7 +4,9 @@ * * A fixture is a throwaway gstack checkout (the working tree's tracked and * untracked-but-not-ignored files, so uncommitted changes are under test) plus - * a clean HOME. Binaries are stubbed and `bun run build` is refused, so setup + * a clean HOME. node_modules is never source, including a worktree's + * node_modules symlink to its main checkout, which git lists as untracked + * because .gitignore's `node_modules/` matches only directories. Binaries are stubbed and `bun run build` is refused, so setup * runs its real registration path without compiling (GSTACK_SKIP_PLAYWRIGHT, * GSTACK_SETUP_SKIP_CSO_BUILD, GSTACK_SKIP_FONTS). Every write the test makes * goes through `put` or the `fixture*Sync` wrappers, which refuse paths @@ -25,7 +27,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; export const ROOT = resolve(import.meta.dir, '../..'); const listed = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 }); if (listed.status !== 0) throw new Error(listed.stderr); -const SOURCE_FILES = listed.stdout.split('\0').filter(rel => rel && !/^(?:test|docs|browse\/test|\.github|node_modules)\//.test(rel) && existsSync(join(ROOT, rel))); +const SOURCE_FILES = listed.stdout.split('\0').filter(rel => rel && !/^(?:test|docs|browse\/test|\.github|node_modules)(?:\/|$)/.test(rel) && existsSync(join(ROOT, rel))); const STUB_BINARIES = ['browse/dist/browse', 'design/dist/design', 'make-pdf/dist/pdf', 'browse/dist/.build-complete']; const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; const POSIX = process.platform !== 'win32'; diff --git a/test/helpers/run-bin.ts b/test/helpers/run-bin.ts index 09b0184e4..a4888d8d9 100644 --- a/test/helpers/run-bin.ts +++ b/test/helpers/run-bin.ts @@ -11,6 +11,8 @@ */ import { spawnSync } from 'node:child_process'; +import { STATE_ROOT_VARS } from '../../lib/state-root'; + export interface RunBinResult { status: number; stdout: string; @@ -22,9 +24,10 @@ export interface RunBinOptions { /** Merged over process.env (an `undefined` value deletes the key). */ env?: Record; /** - * Isolation shorthand: sets GSTACK_HOME + GSTACK_STATE_DIR (gstack-config - * precedence is GSTACK_HOME > GSTACK_STATE_DIR > $HOME/.gstack, so both - * must move to isolate from the operator's real ~/.gstack). + * Isolation shorthand: points every state-root variable (GSTACK_STATE_ROOT, + * GSTACK_HOME, GSTACK_STATE_DIR; lib/state-root.ts) here. GSTACK_STATE_ROOT + * outranks the others, so an exported one would otherwise send the bin's + * writes to that root instead (GST-1659). */ gstackHome?: string; /** Also move $HOME (bins that write $HOME-anchored files, e.g. artifacts-remote pointers). */ @@ -40,8 +43,7 @@ export interface RunBinOptions { export function runBin(command: string, args: string[] = [], opts: RunBinOptions = {}): RunBinResult { const env: Record = { ...process.env, ...opts.env }; if (opts.gstackHome !== undefined) { - env.GSTACK_HOME = opts.gstackHome; - env.GSTACK_STATE_DIR = opts.gstackHome; + for (const name of STATE_ROOT_VARS) env[name] = opts.gstackHome; } if (opts.home !== undefined) env.HOME = opts.home; for (const key of Object.keys(env)) { diff --git a/test/helpers/state-root-isolation.ts b/test/helpers/state-root-isolation.ts new file mode 100644 index 000000000..50299d51e --- /dev/null +++ b/test/helpers/state-root-isolation.ts @@ -0,0 +1,21 @@ +/** + * Keeps a test's spawns off the user's real gstack state (GST-1659). The + * state root comes from a chain (lib/state-root.ts, docs/state-root.md): + * GSTACK_STATE_ROOT, GSTACK_HOME, GSTACK_STATE_DIR, CLAUDE_PLUGIN_DATA when + * CLAUDE_PLUGIN_ROOT names gstack, then $HOME/.gstack. test-setup.ts strips + * two of them, but only when Bun loads this repository's bunfig.toml; a run + * from another directory or with --config=/dev/null keeps them all. + * test/state-root-isolation.test.ts runs guarded files with every link of the + * chain exported to sentinels and fails on any write that reaches one. + */ +import { STATE_ROOT_VARS } from '../../lib/state-root'; + +/** Every variable the state-root chain reads, CLAUDE_PLUGIN_DATA's gate included. */ +export const STATE_CHAIN_VARS = [...STATE_ROOT_VARS, 'CLAUDE_PLUGIN_DATA', 'CLAUDE_PLUGIN_ROOT'] as const; + +/** process.env without the chain and with HOME (USERPROFILE on Windows) at `home`: a spawn on it resolves its state root to home/.gstack. */ +export function isolatedEnv(home: string): Record { + const env = { ...process.env, HOME: home, USERPROFILE: home } as Record; + for (const name of STATE_CHAIN_VARS) delete env[name]; + return env; +} diff --git a/test/state-root-isolation.test.ts b/test/state-root-isolation.test.ts new file mode 100644 index 000000000..390ff08ae --- /dev/null +++ b/test/state-root-isolation.test.ts @@ -0,0 +1,80 @@ +/** + * State-root isolation guard (GST-1659). A guarded test file must keep every + * write under its own temp roots whatever the parent shell exported. Each file + * runs in a child `bun test` with no bunfig preload (test-setup.ts strips two + * state-root variables, but only for runs that load this repository's + * bunfig.toml), with GSTACK_STATE_ROOT, GSTACK_HOME, GSTACK_STATE_DIR, a live + * CLAUDE_PLUGIN_DATA and HOME each pointed at a sentinel directory holding a + * config.yaml. It passes only when the file's tests pass and every sentinel is + * byte-for-byte unchanged. Spawn helpers: test/helpers/state-root-isolation.ts. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { bunIsolationArgs } from '../lib/bun-isolation'; +import { tree } from './helpers/install-fixture'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const GUARDED = ['test/update-channel.test.ts', 'test/update-channel-setup.test.ts']; +const SENTINELS: Record = { GSTACK_STATE_ROOT: 'state-root', GSTACK_HOME: 'gstack-home', GSTACK_STATE_DIR: 'state-dir', CLAUDE_PLUGIN_DATA: 'plugin-data', HOME: 'home' }; + +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +const flatten = (node: unknown, prefix = ''): Record => + node !== null && typeof node === 'object' && !('link' in node) + ? Object.assign({}, ...Object.entries(node).map(([name, child]) => flatten(child, prefix + name + '/'))) + : { [prefix.slice(0, -1)]: JSON.stringify(node) }; + +/** Run one test file with the whole state-root chain and HOME exported to sentinels; report its exit code and every sentinel path it changed. */ +async function runUnderSentinels(file: string) { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-sentinel-'))); + bases.push(base); + const env: Record = { ...(process.env as Record), CLAUDE_PLUGIN_ROOT: '/plugins/gstack', USERPROFILE: path.join(base, 'home'), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: path.join(os.tmpdir(), 'gstack-sentinel-bun-cache') }; + for (const [name, dir] of Object.entries(SENTINELS)) { + env[name] = path.join(base, dir); + const config = path.join(base, dir, name === 'HOME' ? '.gstack' : '', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + fs.writeFileSync(config, 'telemetry: off\n'); + } + const before = flatten(tree(base)); + const child = Bun.spawn([process.execPath, ...bunIsolationArgs(), 'test', file], { cwd: ROOT, env, stdout: 'pipe', stderr: 'pipe', timeout: 300_000 }); + const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + const after = flatten(tree(base)); + const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => before[k] !== after[k]).sort(); + return { code, changed, output: stdout + stderr }; +} + +describe.skipIf(process.platform === 'win32')('state-root isolation guard', () => { + // Value: protects=each guarded file passes with every state-root variable and HOME exported, and leaves all of them untouched; fails_when=a spawn in the + // file inherits the parent's GSTACK_STATE_ROOT, GSTACK_HOME, GSTACK_STATE_DIR, CLAUDE_PLUGIN_DATA or HOME and writes the user's real config (GST-1659); + // why_new=test-setup.ts hid the leak only for runs that load bunfig.toml; seam=sentinel directories stand in for the user's state roots; + // no_claim=files not in GUARDED, and writes outside the chain and HOME + for (const file of GUARDED) { + test(`${file} passes and writes nothing to an exported state root or HOME`, async () => { + const r = await runUnderSentinels(file); + expect(r.changed, r.output).toEqual([]); + expect(r.code, r.output).toBe(0); + }, 330_000); + } + + // Value: protects=the guard sees a write through an inherited state root; fails_when=the sentinel diff or the exported chain stops detecting the + // original bug (gstack-config set on the inherited environment); why_new=negative control for the guard above; seam=a planted test file; no_claim=none + test('a planted test that runs gstack-config set on the inherited environment is caught writing the exported GSTACK_STATE_ROOT', async () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-sentinel-planted-'))); + bases.push(dir); + const planted = path.join(dir, 'planted-leak.test.ts'); + fs.writeFileSync(planted, `import { expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +test('leaks', () => { + expect(spawnSync(${JSON.stringify(path.join(ROOT, 'bin', 'gstack-config'))}, ['set', 'update_channel', 'stable'], { timeout: 30_000 }).status).toBe(0); +}); +`); + const r = await runUnderSentinels(planted); + expect(r.code, r.output).toBe(0); + expect(r.changed).toEqual(['state-root/config.yaml']); + }, 60_000); +}); diff --git a/test/update-channel.test.ts b/test/update-channel.test.ts index 6929fa7a9..23c789b4b 100644 --- a/test/update-channel.test.ts +++ b/test/update-channel.test.ts @@ -8,13 +8,14 @@ * follows the same ref, and bin/gstack-update-check compares with the channel * so a stable install is not told that main moved. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { execFileSync, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { runBin } from './helpers/run-bin'; +import { isolatedEnv } from './helpers/state-root-isolation'; const ROOT = path.resolve(import.meta.dir, '..'); const HELPER = path.join(ROOT, 'bin', 'gstack-update-channel.sh'); @@ -22,10 +23,14 @@ const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config'); const SESSION_UPDATE = path.join(ROOT, 'bin', 'gstack-session-update'); const bases: string[] = []; afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); +/** Every spawn here starts from ENV: no state-root variable from the parent shell and HOME in a temp dir, so nothing reaches the user's real config (GST-1659; test/state-root-isolation.test.ts). */ +const SAFE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-home-')); +afterAll(() => fs.rmSync(SAFE_HOME, { recursive: true, force: true })); +const ENV = isolatedEnv(SAFE_HOME); const GIT_ENV = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_CONFIG_NOSYSTEM: '1' }; function git(cwd: string, ...args: string[]): string { - return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...GIT_ENV } }).trim(); + return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...ENV, ...GIT_ENV } }).trim(); } const write = (file: string, text: string, mode = 0o644) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text, { mode }); }; @@ -40,7 +45,7 @@ describe.skipIf(process.platform === 'win32')('gstack-update-channel.sh: resolut const resolve = (team: string, channel: string, extra: Record = {}) => { fs.writeFileSync(path.join(base, 'team'), team); fs.writeFileSync(path.join(base, 'channel'), channel); - const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel "$2"', '_', HELPER, base], { encoding: 'utf8', env: { ...process.env, TEAM_MODE_FILE: path.join(base, 'team'), CHANNEL_FILE: path.join(base, 'channel'), GSTACK_UPDATE_CHANNEL: '', ...extra }, timeout: 10_000 }); + const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel "$2"', '_', HELPER, base], { encoding: 'utf8', env: { ...ENV, TEAM_MODE_FILE: path.join(base, 'team'), CHANNEL_FILE: path.join(base, 'channel'), GSTACK_UPDATE_CHANNEL: '', ...extra }, timeout: 10_000 }); return r.stdout.trim() + (r.status === 0 ? '' : ' exit=' + r.status); }; return { base, resolve }; @@ -60,9 +65,9 @@ describe.skipIf(process.platform === 'win32')('gstack-update-channel.sh: resolut }); test('the refspec names stable only; a missing gstack-config means main', () => { - const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_refspec stable && gstack_update_channel_refspec main | wc -c && gstack_update_channel /nonexistent', '_', HELPER], { encoding: 'utf8', timeout: 10_000, env: { ...process.env, GSTACK_UPDATE_CHANNEL: '' } }); + const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_refspec stable && gstack_update_channel_refspec main | wc -c && gstack_update_channel /nonexistent', '_', HELPER], { encoding: 'utf8', timeout: 10_000, env: { ...ENV, GSTACK_UPDATE_CHANNEL: '' } }); expect(r.stdout.split('\n').map(l => l.trim())).toEqual(['+refs/heads/stable:refs/remotes/origin/stable', '1', 'main', '']); - expect(spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_missing "fatal: couldn\'t find remote ref refs/heads/stable" && ! gstack_update_channel_missing "fatal: unable to access"', '_', HELPER], { encoding: 'utf8', timeout: 10_000 }).status).toBe(0); + expect(spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_missing "fatal: couldn\'t find remote ref refs/heads/stable" && ! gstack_update_channel_missing "fatal: unable to access"', '_', HELPER], { encoding: 'utf8', timeout: 10_000, env: ENV }).status).toBe(0); }); }); @@ -74,7 +79,7 @@ describe('gstack-config update_channel', () => { test('defaults to auto, accepts auto|stable|main, and refuses anything else without changing the stored value', () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-config-')); bases.push(home); - const run = (args: string[]) => runBin(CONFIG_BIN, args, { gstackHome: home, cwd: ROOT, trim: true }); + const run = (args: string[]) => runBin(CONFIG_BIN, args, { gstackHome: home, home: SAFE_HOME, cwd: ROOT, trim: true }); expect(run(['get', 'update_channel']).stdout).toBe('auto'); expect(run(['defaults']).stdout).toMatch(/update_channel:\s+auto$/m); expect(run(['list']).stdout).toMatch(/update_channel:\s+auto \(default\)$/m); @@ -102,7 +107,7 @@ function world(opts: { installAt?: 'A' | 'C'; stable?: boolean } = {}) { const state = path.join(base, 'state'); const home = path.join(base, 'home'); fs.mkdirSync(state); fs.mkdirSync(home); - execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000, env: ENV }); fs.mkdirSync(seed); git(seed, 'init', '-q', '-b', 'main'); git(seed, 'remote', 'add', 'origin', origin); @@ -124,14 +129,14 @@ function world(opts: { installAt?: 'A' | 'C'; stable?: boolean } = {}) { shas[name] = git(seed, 'rev-parse', 'HEAD'); }; release('A', '1.0.0'); - if (opts.installAt !== 'C') execFileSync('git', ['clone', '-q', '--single-branch', '--depth', '1', 'file://' + origin, install], { timeout: 30_000 }); + if (opts.installAt !== 'C') execFileSync('git', ['clone', '-q', '--single-branch', '--depth', '1', 'file://' + origin, install], { timeout: 30_000, env: ENV }); release('B', '1.0.1'); release('C', '1.0.2'); - if (opts.installAt === 'C') execFileSync('git', ['clone', '-q', '--single-branch', '--depth', '1', 'file://' + origin, install], { timeout: 30_000 }); + if (opts.installAt === 'C') execFileSync('git', ['clone', '-q', '--single-branch', '--depth', '1', 'file://' + origin, install], { timeout: 30_000, env: ENV }); if (opts.stable !== false) git(seed, 'push', '-q', 'origin', shas.B + ':refs/heads/stable'); const team = path.join(base, 'team'), channel = path.join(base, 'channel'), calls = path.join(base, 'setup-calls'); fs.writeFileSync(team, 'true'); fs.writeFileSync(channel, ''); - const env = { ...process.env, ...GIT_ENV, HOME: home, GSTACK_DIR: install, GSTACK_STATE_ROOT: state, TEAM_MODE_FILE: team, CHANNEL_FILE: channel, SETUP_CALLS: calls, TMPDIR: base, GSTACK_UPDATE_CHANNEL: '' }; + const env = { ...ENV, ...GIT_ENV, HOME: home, GSTACK_DIR: install, GSTACK_STATE_ROOT: state, TEAM_MODE_FILE: team, CHANNEL_FILE: channel, SETUP_CALLS: calls, TMPDIR: base, GSTACK_UPDATE_CHANNEL: '' }; return { base, origin, seed, install, state, shas, team, channel, env, head: () => git(install, 'rev-parse', 'HEAD') }; } type World = ReturnType; @@ -263,7 +268,7 @@ describe.skipIf(process.platform === 'win32')('the /gstack-upgrade Step 4 fence expect(ahead.head()).toBe(ahead.shas.C); expect(git(ahead.install, 'rev-parse', 'refs/remotes/origin/stable')).toBe(ahead.shas.B); // The depth-1 histories never connect, so ancestry said nothing and the VERSION order decided. - expect(spawnSync('git', ['-C', ahead.install, 'merge-base', '--is-ancestor', 'origin/stable', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).status).toBe(1); + expect(spawnSync('git', ['-C', ahead.install, 'merge-base', '--is-ancestor', 'origin/stable', 'HEAD'], { encoding: 'utf8', timeout: 10_000, env: ENV }).status).toBe(1); expect(fs.existsSync(path.join(ahead.base, 'setup-calls'))).toBe(false); const behind = world({ installAt: 'A' }); @@ -320,23 +325,23 @@ describe.skipIf(process.platform === 'win32')('gstack-update-check on the stable bases.push(base); const gstack = path.join(base, 'gstack'), origin = path.join(base, 'origin.git'), state = path.join(base, 'state'), raw = path.join(base, 'raw'), shim = path.join(base, 'shim'); for (const d of [path.join(gstack, 'bin'), state, raw, shim]) fs.mkdirSync(d, { recursive: true }); - const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 10_000 }).trim(); + const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 10_000, env: ENV }).trim(); const gitLog = path.join(base, 'git.log'); fs.writeFileSync(path.join(shim, 'git'), '#!/usr/bin/env bash\ncase "$1" in ls-remote) echo "git $*" >> "' + gitLog + '" ;; esac\nexec "' + realGit + '" "$@"\n', { mode: 0o755 }); for (const name of ['gstack-config', 'gstack-egress-lib.sh', 'gstack-state-root.sh', 'gstack-egress-receipt', 'gstack-update-channel.sh']) fs.symlinkSync(path.join(ROOT, 'bin', name), path.join(gstack, 'bin', name)); fs.writeFileSync(path.join(gstack, 'VERSION'), '1.0.0\n'); fs.writeFileSync(path.join(gstack, '.gitignore'), 'bin/\n'); - execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000, env: ENV }); git(gstack, 'init', '-q', '-b', 'main'); git(gstack, 'add', '-A'); git(gstack, 'commit', '-q', '-m', 'base'); git(gstack, 'remote', 'add', 'origin', origin); git(gstack, 'push', '-q', 'origin', 'main'); git(gstack, 'push', '-q', 'origin', 'main:stable'); const local = git(gstack, 'rev-parse', 'HEAD'); const clone = path.join(base, 'upstream'); - execFileSync('git', ['clone', '-q', origin, clone], { timeout: 30_000 }); + execFileSync('git', ['clone', '-q', origin, clone], { timeout: 30_000, env: ENV }); fs.writeFileSync(path.join(clone, 'VERSION'), '1.0.1\n'); git(clone, 'commit', '-qam', 'main moved'); git(clone, 'push', '-q', 'origin', 'HEAD:main'); const main = git(clone, 'rev-parse', 'HEAD'); for (const [sha, v] of [[local, '1.0.0'], [main, '1.0.1']]) { fs.mkdirSync(path.join(raw, sha!)); fs.writeFileSync(path.join(raw, sha!, 'VERSION'), v + '\n'); } - const env = { ...process.env, ...GIT_ENV, HOME: base, GSTACK_DIR: gstack, GSTACK_STATE_ROOT: state, GSTACK_REMOTE_REPO: origin, GSTACK_REMOTE_RAW_BASE: 'file://' + raw, GSTACK_COMPARE_API: 'file://' + path.join(base, 'compare'), PATH: shim + ':' + process.env.PATH }; + const env = { ...ENV, ...GIT_ENV, HOME: base, GSTACK_DIR: gstack, GSTACK_STATE_ROOT: state, GSTACK_REMOTE_REPO: origin, GSTACK_REMOTE_RAW_BASE: 'file://' + raw, GSTACK_COMPARE_API: 'file://' + path.join(base, 'compare'), PATH: shim + ':' + process.env.PATH }; delete (env as Record).GSTACK_REMOTE_URL; const run = (extra: Record) => spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-update-check')], { encoding: 'utf8', timeout: 30_000, env: { ...env, ...extra } }); const stable = run({ GSTACK_UPDATE_CHANNEL: 'stable' }); @@ -383,7 +388,7 @@ describe.skipIf(process.platform === 'win32')('gstack_update_channel_initial: th bases.push(base); const origin = path.join(base, 'origin.git'), seed = path.join(base, 'seed'), home = path.join(base, 'home'), state = path.join(base, 'state'); fs.mkdirSync(home); - execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000, env: ENV }); fs.mkdirSync(seed); git(seed, 'init', '-q', '-b', 'main'); write(path.join(seed, 'VERSION'), '1.0.0\n'); @@ -392,12 +397,12 @@ describe.skipIf(process.platform === 'win32')('gstack_update_channel_initial: th git(seed, 'push', '-q', origin, 'main'); const clone = (name: string, ...flags: string[]) => { const dir = path.join(base, name); - execFileSync('git', ['clone', '-q', ...flags, 'file://' + origin, dir], { timeout: 30_000, env: { ...process.env, ...GIT_ENV, HOME: home } }); + execFileSync('git', ['clone', '-q', ...flags, 'file://' + origin, dir], { timeout: 30_000, env: { ...ENV, ...GIT_ENV, HOME: home } }); return dir; }; const initial = (dir: string, extra: Record = {}) => spawnSync('bash', [HELPER, '--initial', state, dir], { encoding: 'utf8', timeout: 10_000, - env: { ...process.env, ...GIT_ENV, HOME: home, GSTACK_UPDATE_CHANNEL: '', GSTACK_CONTRIBUTOR: '', CI: '', CAPY_MACHINE_CONFIG: '', ...extra }, + env: { ...ENV, ...GIT_ENV, HOME: home, GSTACK_UPDATE_CHANNEL: '', GSTACK_CONTRIBUTOR: '', CI: '', CAPY_MACHINE_CONFIG: '', ...extra }, }).stdout.trim(); return { base, home, state, clone, initial }; } @@ -414,7 +419,7 @@ describe.skipIf(process.platform === 'win32')('gstack_update_channel_initial: th fs.mkdirSync(path.join(c.home, '.capy', 'system'), { recursive: true }); expect(c.initial(shallow)).toBe('main Capy machine: crews follow every merge'); fs.rmSync(path.join(c.home, '.capy'), { recursive: true }); - const usage = spawnSync('bash', [HELPER, '--initial'], { encoding: 'utf8', timeout: 10_000 }); + const usage = spawnSync('bash', [HELPER, '--initial'], { encoding: 'utf8', timeout: 10_000, env: ENV }); expect([usage.status, usage.stderr.trim()]).toEqual([2, 'usage: gstack-update-channel.sh --initial []']); });