mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
273 lines
15 KiB
TypeScript
273 lines
15 KiB
TypeScript
/**
|
|
* C9: destructive gstack-upgrade and /spec fences in a fresh shell.
|
|
*
|
|
* Hosts run each fenced block in a new shell, so variables from Step 2 are
|
|
* gone. `cd ""` succeeds and stays put, which used to point
|
|
* `git checkout -- '*\/SKILL.md'`, `git stash` and `git reset --hard` at the
|
|
* user's own project. Every host's render is executed with the guarded
|
|
* variable unset, empty, missing, unreadable and pointing at the wrong
|
|
* repository: each must exit before any mutating git command and leave the
|
|
* scratch project byte-for-byte unchanged. Positive controls prove the guard
|
|
* still lets a real gstack checkout through.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import { execFile, spawnSync } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { dirname, join } from 'path';
|
|
import { createHash } from 'crypto';
|
|
import { runGeneration } from '../scripts/gen-skill-docs';
|
|
import { ALL_HOST_CONFIGS } from '../hosts/index';
|
|
|
|
const IS_WINDOWS = process.platform === 'win32';
|
|
let root = '';
|
|
let renders = '';
|
|
|
|
function sh(cwd: string, cmd: string, env: Record<string, string> = {}) {
|
|
return spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: 20_000, env: { ...process.env, ...fixtureEnv(), ...env } });
|
|
}
|
|
function fixtureEnv(): Record<string, string> {
|
|
return {
|
|
HOME: join(root, 'home'),
|
|
GIT_CONFIG_GLOBAL: join(root, 'home', '.gitconfig'),
|
|
GIT_CONFIG_NOSYSTEM: '1',
|
|
GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test',
|
|
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test',
|
|
};
|
|
}
|
|
function mustSh(cwd: string, cmd: string) {
|
|
const r = sh(cwd, cmd);
|
|
if (r.status !== 0) throw new Error(`${cmd}\n${r.stdout}\n${r.stderr}`);
|
|
return r.stdout;
|
|
}
|
|
|
|
/** A directory that looks like a gstack checkout (VERSION, setup, bin/gstack-config). */
|
|
function makeGstackLike(dir: string, git: boolean) {
|
|
mkdirSync(join(dir, 'bin'), { recursive: true });
|
|
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
writeFileSync(join(dir, 'VERSION'), '1.0.0.0\n');
|
|
writeFileSync(join(dir, 'setup'), '#!/bin/sh\necho SETUP_RAN "$@"\n', { mode: 0o755 });
|
|
writeFileSync(join(dir, 'bin', 'gstack-config'), '#!/bin/sh\necho false\n', { mode: 0o755 });
|
|
writeFileSync(join(dir, 'bin', 'gstack-autoplan'), '#!/bin/sh\necho "## Coordinator contract (fixture)"\n', { mode: 0o755 });
|
|
writeFileSync(join(dir, 'bin', 'gstack-ff-update'), readFileSync(join(import.meta.dir, '..', 'bin', 'gstack-ff-update')), { mode: 0o755 });
|
|
if (git) mustSh(dir, 'git init -q -b main && git add -A && git commit -q -m init');
|
|
}
|
|
|
|
function bashFences(text: string): string[] {
|
|
return [...text.matchAll(/^```bash\n([\s\S]*?)\n```$/gm)].map(m => m[1]);
|
|
}
|
|
|
|
function fence(fences: string[], marker: string): string {
|
|
const hits = fences.filter(f => f.includes(marker));
|
|
if (hits.length !== 1) throw new Error(`expected one fence containing ${marker}, found ${hits.length}`);
|
|
return hits[0];
|
|
}
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
async function bashAsync(cwd: string, script: string, env: Record<string, string>) {
|
|
try {
|
|
const r = await execFileAsync('bash', ['-c', script], { cwd, timeout: 20_000, env: { ...process.env, ...fixtureEnv(), ...env } });
|
|
return { status: 0, stdout: r.stdout, stderr: r.stderr };
|
|
} catch (error) {
|
|
const e = error as { code?: number | string; stdout?: string; stderr?: string };
|
|
return { status: typeof e.code === 'number' ? e.code : -1, stdout: e.stdout ?? '', stderr: e.stderr ?? String(error) };
|
|
}
|
|
}
|
|
|
|
/** Tree snapshot of the scratch project: file bytes + git HEAD, index, status and stash. */
|
|
async function snapshot(dir: string): Promise<string> {
|
|
const h = createHash('sha256');
|
|
const walk = (d: string) => {
|
|
for (const name of readdirSync(d).sort()) {
|
|
if (name === '.git') continue;
|
|
const p = join(d, name);
|
|
const st = statSync(p);
|
|
if (st.isDirectory()) walk(p);
|
|
else h.update(`${p}\0`).update(readFileSync(p));
|
|
}
|
|
};
|
|
walk(dir);
|
|
const state = await bashAsync(dir, 'git rev-parse HEAD; git status --porcelain=v1 -uall; git stash list; git ls-files -s; git worktree list --porcelain', {});
|
|
h.update(state.stdout);
|
|
return h.digest('hex');
|
|
}
|
|
|
|
interface HostRender { host: string; upgrade: string[]; spec: string[]; localDir: string; globalRoot: string }
|
|
const hostRenders: HostRender[] = [];
|
|
|
|
beforeAll(async () => {
|
|
if (IS_WINDOWS) return;
|
|
root = mkdtempSync(join(tmpdir(), 'gstack-c9-'));
|
|
mkdirSync(join(root, 'home'), { recursive: true });
|
|
writeFileSync(join(root, 'home', '.gitconfig'), '[init]\n\tdefaultBranch = main\n[commit]\n\tgpgsign = false\n');
|
|
renders = join(root, 'render');
|
|
const result = await runGeneration({ host: 'all', outputRoot: renders });
|
|
if (result.exitCode !== 0) throw new Error(result.diagnostics.map(d => d.message).join('\n'));
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
const base = config.name === 'claude' ? renders : join(renders, config.hostSubdir, 'skills');
|
|
const upgradePath = config.name === 'claude' ? join(base, 'gstack-upgrade', 'SKILL.md') : join(base, 'gstack-upgrade', 'SKILL.md');
|
|
const specPath = config.name === 'claude' ? join(base, 'spec', 'sections', 'gate-and-file.md') : join(base, 'gstack-spec', 'SKILL.md');
|
|
const upgrade = bashFences(readFileSync(upgradePath, 'utf8'));
|
|
const spec = bashFences(readFileSync(specPath, 'utf8'));
|
|
const team = fence(upgrade, 'git rm -r --cached');
|
|
const localDir = team.match(/git rm -r --cached (\S+)\/ /)![1];
|
|
hostRenders.push({ host: config.name, upgrade, spec, localDir, globalRoot: config.globalRoot });
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (!root) return;
|
|
spawnSync('chmod', ['-R', 'u+rwx', root], { timeout: 20_000 });
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Per-host world: a user project with a vendored copy and a dirty tracked file. */
|
|
function world(host: HostRender) {
|
|
const w = mkdtempSync(join(root, `${host.host}-`));
|
|
const project = join(w, 'project');
|
|
const stub = join(w, 'stub');
|
|
mkdirSync(join(project, 'app'), { recursive: true });
|
|
mkdirSync(stub);
|
|
makeGstackLike(join(project, host.localDir), false);
|
|
writeFileSync(join(project, 'app', 'SKILL.md'), 'committed\n');
|
|
mustSh(project, 'git init -q -b main && git add -A && git commit -q -m init');
|
|
writeFileSync(join(project, 'app', 'SKILL.md'), 'user edit in progress\n');
|
|
writeFileSync(join(project, 'notes.txt'), 'untracked user work\n');
|
|
const gitLog = join(w, 'git.log');
|
|
const realGit = mustSh(w, 'command -v git').trim();
|
|
// Logs every call; clone never reaches the network (the vendored fence would fetch GitHub).
|
|
writeFileSync(join(stub, 'git'), `#!/bin/sh\nprintf '%s\\n' "$*" >> "${gitLog}"\n[ "$1" = clone ] && exit 128\nexec "${realGit}" "$@"\n`, { mode: 0o755 });
|
|
writeFileSync(join(stub, 'claude'), `#!/bin/sh\ncat > /dev/null\necho spawned >> "${join(w, 'claude.log')}"\n`, { mode: 0o755 });
|
|
const gstack = join(w, 'gstack');
|
|
makeGstackLike(gstack, true);
|
|
mustSh(w, `git clone -q --bare gstack origin.git && cd gstack && git remote add origin "${join(w, 'origin.git')}"`);
|
|
const plainRepo = join(w, 'plain');
|
|
mkdirSync(join(plainRepo, 'bin'), { recursive: true });
|
|
writeFileSync(join(plainRepo, 'VERSION'), '0.1.0\n');
|
|
writeFileSync(join(plainRepo, 'setup'), '#!/bin/sh\n');
|
|
mustSh(plainRepo, 'git init -q -b main && git add -A && git commit -q -m init');
|
|
return { w, project, stub, gitLog, gstack, plainRepo };
|
|
}
|
|
|
|
async function run(wd: ReturnType<typeof world>, script: string, vars: Record<string, string | undefined>) {
|
|
writeFileSync(wd.gitLog, '');
|
|
const env: Record<string, string> = { PATH: `${wd.stub}:${process.env.PATH}`, GSTACK_ROOT: wd.gstack, GSTACK_BIN: join(wd.gstack, 'bin') };
|
|
const unset: string[] = [];
|
|
for (const [k, v] of Object.entries(vars)) {
|
|
if (v === undefined) unset.push(k); else env[k] = v;
|
|
}
|
|
const r = await bashAsync(wd.project, `unset ${unset.join(' ') || '_NONE_'}\n${script}`, env);
|
|
const gitCalls = readFileSync(wd.gitLog, 'utf8').split('\n').filter(Boolean);
|
|
return { ...r, gitCalls };
|
|
}
|
|
|
|
const BAD_VALUES = ['unset', 'empty', 'nonexistent', 'unreadable', 'wrong-repo', 'subdirectory'] as const;
|
|
function badValue(wd: ReturnType<typeof world>, kind: typeof BAD_VALUES[number]): string | undefined {
|
|
switch (kind) {
|
|
case 'unset': return undefined;
|
|
case 'empty': return '';
|
|
case 'nonexistent': return join(wd.w, 'missing');
|
|
case 'unreadable': {
|
|
const locked = join(wd.w, 'locked');
|
|
if (!existsSync(locked)) { mkdirSync(locked); chmodSync(locked, 0o000); }
|
|
return locked;
|
|
}
|
|
case 'wrong-repo': return wd.plainRepo;
|
|
case 'subdirectory': return join(wd.gstack, 'bin');
|
|
}
|
|
}
|
|
|
|
describe.skipIf(IS_WINDOWS)('C9: destructive upgrade/spec fences refuse bad paths on every host', () => {
|
|
type Kind = typeof BAD_VALUES[number];
|
|
const UNUSABLE: Kind[] = ['unset', 'empty', 'nonexistent', 'unreadable'];
|
|
const cases: Array<{ name: string; doc: 'upgrade' | 'spec'; marker: string; target: string; kinds?: Kind[]; others: (wd: ReturnType<typeof world>, h: HostRender) => Record<string, string> }> = [
|
|
{ name: 'ff-only upgrade', doc: 'upgrade', marker: 'bash bin/gstack-ff-update', target: 'INSTALL_DIR', others: () => ({}) },
|
|
{ name: 'reset fallback', doc: 'upgrade', marker: 'git reset --hard "${UPSTREAM_REF:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"', target: 'INSTALL_DIR', others: () => ({}) },
|
|
{ name: 'vendored swap', doc: 'upgrade', marker: 'git clone --depth 1', target: 'INSTALL_DIR', others: () => ({}) },
|
|
{ name: 'local copy detection', doc: 'upgrade', marker: '_RESOLVED_PRIMARY=', target: 'INSTALL_DIR', kinds: UNUSABLE, others: () => ({}) },
|
|
{ name: 'team-mode removal', doc: 'upgrade', marker: 'git rm -r --cached', target: 'LOCAL_GSTACK', others: () => ({}) },
|
|
{ name: 'vendored sync (local)', doc: 'upgrade', marker: 'LOCAL_SYNC_OK', target: 'LOCAL_GSTACK', others: (wd, h) => ({ INSTALL_DIR: wd.gstack }) },
|
|
{ name: 'vendored sync (primary)', doc: 'upgrade', marker: 'LOCAL_SYNC_OK', target: 'INSTALL_DIR', others: (wd, h) => ({ LOCAL_GSTACK: join(wd.project, h.localDir) }) },
|
|
{ name: 'spec worktree add', doc: 'spec', marker: 'git worktree add', target: 'SPAWN_PATH', kinds: ['unset', 'empty'], others: () => ({ SPAWN_BRANCH: 'spec/x-1', PIN_SHA: 'HEAD' }) },
|
|
{ name: 'spec spawn', doc: 'spec', marker: 'claude -p', target: 'SPAWN_PATH', kinds: [...UNUSABLE, 'subdirectory'], others: (wd) => ({ SPAWN_BRANCH: 'spec/x-1', ARCHIVE_PATH: join(wd.gstack, 'VERSION') }) },
|
|
];
|
|
|
|
async function refusals(h: HostRender): Promise<string[]> {
|
|
const wd = world(h);
|
|
const failures: string[] = [];
|
|
const baseline = await snapshot(wd.project);
|
|
for (const c of cases) {
|
|
const script = fence(c.doc === 'upgrade' ? h.upgrade : h.spec, c.marker);
|
|
for (const kind of c.kinds ?? BAD_VALUES) {
|
|
const r = await run(wd, script, { ...c.others(wd, h), [c.target]: badValue(wd, kind) });
|
|
const mutating = r.gitCalls.filter(call => !/^rev-parse\b/.test(call));
|
|
const changed = (await snapshot(wd.project)) !== baseline;
|
|
const spawned = existsSync(join(wd.w, 'claude.log'));
|
|
if (r.status === 0 || mutating.length > 0 || changed || spawned) {
|
|
failures.push(`${h.host}: ${c.name} with ${c.target} ${kind}: exit=${r.status} git=[${mutating.join(' | ')}] changed=${changed} spawned=${spawned}\n${r.stderr.slice(0, 400)}`);
|
|
}
|
|
}
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
test('every host: each guarded fence exits before mutating git and leaves the project unchanged', async () => {
|
|
expect(hostRenders.length).toBe(ALL_HOST_CONFIGS.length);
|
|
const failures = (await Promise.all(hostRenders.map(refusals))).flat();
|
|
expect(failures).toEqual([]);
|
|
}, 120_000);
|
|
|
|
async function controls(h: HostRender): Promise<string[]> {
|
|
const wd = world(h);
|
|
const problems: string[] = [];
|
|
const ff = await run(wd, fence(h.upgrade, 'bash bin/gstack-ff-update'), { INSTALL_DIR: wd.gstack });
|
|
if (!ff.stdout.includes('FF_OK') || !ff.gitCalls.some(c => c.startsWith('fetch'))) problems.push(`${h.host}: ff-only upgrade did not run: ${ff.stderr}`);
|
|
const local = join(wd.project, h.localDir);
|
|
const team = await run(wd, fence(h.upgrade, 'git rm -r --cached'), { LOCAL_GSTACK: local });
|
|
if (team.status !== 0 || existsSync(local)) problems.push(`${h.host}: team-mode removal did not run: ${team.stderr}`);
|
|
mustSh(wd.project, `git worktree add -q "${join(wd.w, 'wt')}" -b spec/x-1 HEAD`);
|
|
// The spawn reads the contract from this host's ~/<globalRoot>/bin: install the fixture there.
|
|
const installed = join(root, 'home', h.globalRoot);
|
|
mkdirSync(dirname(installed), { recursive: true });
|
|
if (!existsSync(installed)) symlinkSync(wd.gstack, installed);
|
|
const spawn = await run(wd, fence(h.spec, 'claude -p'), { SPAWN_PATH: join(wd.w, 'wt'), SPAWN_BRANCH: 'spec/x-1', ARCHIVE_PATH: join(wd.gstack, 'VERSION') });
|
|
const deadline = Date.now() + 5_000;
|
|
while (!existsSync(join(wd.w, 'claude.log')) && Date.now() < deadline) await Bun.sleep(50);
|
|
if (spawn.status !== 0 || !existsSync(join(wd.w, 'claude.log'))) problems.push(`${h.host}: spec spawn did not start claude: ${spawn.stderr}`);
|
|
return problems;
|
|
}
|
|
|
|
test('every host: positive controls still run on a real checkout', async () => {
|
|
const problems = (await Promise.all(hostRenders.map(controls))).flat();
|
|
expect(problems).toEqual([]);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe.skipIf(IS_WINDOWS)('DX-7: Step 2 finds the registered source checkout for this host', () => {
|
|
test('codex: the README ~/gstack clone recorded in the install registry is the install dir', () => {
|
|
const step2 = fence(hostRenders.find(r => r.host === 'codex')!.upgrade, 'Install type: $INSTALL_TYPE at $INSTALL_DIR');
|
|
const w = mkdtempSync(join(root, 'dx7-'));
|
|
const home = join(w, 'home');
|
|
const state = join(w, 'state');
|
|
const clone = join(home, 'gstack');
|
|
makeGstackLike(clone, true);
|
|
mkdirSync(state, { recursive: true });
|
|
// Setup's Codex runtime root carries bin/ and lib/ linked from the clone; it has no .git.
|
|
const runtime = join(home, '.codex', 'skills', 'gstack');
|
|
mkdirSync(join(runtime, 'bin'), { recursive: true });
|
|
mkdirSync(join(runtime, 'lib'), { recursive: true });
|
|
writeFileSync(join(runtime, 'bin', 'gstack-paths'), `#!/bin/sh\necho "${state}"\n`, { mode: 0o755 });
|
|
writeFileSync(join(state, 'installs.tsv'), [
|
|
['claude', 'global', '-', '/x', '/x', '/elsewhere', '1.0', '-', 'committed', '0'].join('\t'),
|
|
['codex', 'global', '-', join(home, '.codex', 'skills'), runtime, clone, '1.0.0.0', 'false', 'committed', '0'].join('\t'),
|
|
].join('\n') + '\n');
|
|
const cwd = mkdtempSync(join(w, 'cwd-'));
|
|
const run = () => spawnSync('env', ['-i', `HOME=${home}`, `PATH=${process.env.PATH}`, 'bash', '-c', step2], { cwd, encoding: 'utf8', timeout: 20_000 });
|
|
const r = run();
|
|
expect(r.stdout.trim(), r.stderr).toBe(`Install type: global-git at ${clone}`);
|
|
rmSync(join(state, 'installs.tsv'));
|
|
expect(run().stdout).toContain('ERROR: gstack not found');
|
|
});
|
|
});
|