fix(#2624): write the .gsd-source marker before staging reads it (#2811)

* test(#2624): failing-first regression for stale .gsd-source marker read-before-write

Adds failing-first regression proving a Claude-global upgrade currently lets
staging read a stale prior-version .gsd-source marker before install() rewrites
it. Pre-seeds a stale marker pointing at a still-existing fake source, spies on
findInstallSourceRoot to capture which source staging resolves, and asserts
staging never resolves the stale path. Also covers fresh-install and ghost-marker
negative space.

* fix(#2624): write the .gsd-source marker before staging reads it

A Claude-global upgrade silently installed skill content from the PREVIOUS
version. findInstallSourceRoot prefers <configDir>/.gsd-source, and install()
used to rewrite that marker AFTER staging had already read it — so on an
upgrade the marker still pointed at the prior version's source (an npx cache
dir that still exists on disk) and every converted skill was generated from
the OLD commands/gsd, with generateManifest then recording the stale content's
hash as correct.

Extract the marker write into _writeGsdSourceMarker(runtime, targetDir, src,
isGlobal) and call it BEFORE the staging pass (before the _isSkillsRuntime
branch), preserving the original sourceMarkerFile && isGlobal guard, the
half-published-package existsSync guard, and the non-fatal write-failure warn.
This closes the read-before-write hole for every findInstallSourceRoot
consumer (skills, commands, /gsd-surface, capability-state) in one move.

Long-standing (marker write added in ee6f3b70c / #1477, already in v1.7.0);
triggers on any Claude-global upgrade where skill content changed and the
prior source path still exists (the common npx-persistent-cache case).

* test(#2624): use t.mock.method for process.exit/console per CONTRIBUTING rules

Address code-review finding: replace manual process.exit/console monkeypatch
and try/finally in the #2624 test helpers with t.mock.method (auto-restored),
matching CONTRIBUTING.md test conventions and the t.mock.method idiom used
elsewhere in the suite.

* docs(changeset): #2624 install-source-root-stale-marker

* docs(changeset): backfill #2624 PR number to 2811
This commit is contained in:
Tom Boucher
2026-07-28 23:09:10 -04:00
committed by GitHub
parent 7f13ee5373
commit 46bae2f9ff
3 changed files with 189 additions and 29 deletions

View File

@@ -936,3 +936,143 @@ describe('#1477 .gsd-source marker provisioning', () => {
});
});
});
// ─── #2624: stale .gsd-source marker read before rewrite on upgrade ──────────
//
// Regression for #2624: a Claude-global upgrade silently installed skill content
// from the PREVIOUS version. findInstallSourceRoot prefers <configDir>/.gsd-source,
// and install() used to REWRITE that marker AFTER staging had already read it — so
// on an upgrade the marker still pointed at the prior version's source (an npx
// cache dir that still exists on disk) and every converted skill was generated from
// the OLD commands/gsd. Fix: write the marker BEFORE staging reads it.
//
// These exercise the real install(true, 'claude') with HOME redirected to a tmp dir,
// pre-seeding a STALE marker that points at a different (still-existing) source —
// the exact upgrade condition. No live npx / network.
describe('#2624 .gsd-source marker is rewritten before staging reads it', () => {
let tmpRoot;
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
let savedTestMode;
// Run install() with process.exit and console output mocked via t.mock (auto-restored),
// per CONTRIBUTING.md test rules (no manual monkeypatch / try-finally in test bodies).
// process.exit during install is a hard failure — surface it, don't let it kill the runner.
function runInstall(t, isGlobal, runtime) {
t.mock.method(process, 'exit', (code) => {
throw new Error(`process.exit(${code}) during install — should not happen`);
});
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'error', () => {});
return install(isGlobal, runtime);
}
beforeEach(() => {
tmpRoot = createTempDir('gsd-2624-');
savedHome = process.env.HOME;
savedUserProfile = process.env.USERPROFILE;
process.env.HOME = tmpRoot;
process.env.USERPROFILE = tmpRoot;
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
savedTestMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
});
afterEach(() => {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedExplicitConfigDir === undefined) delete process.env.GSD_EXPLICIT_CONFIG_DIR;
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = savedTestMode;
cleanup(tmpRoot);
});
// The current package's commands/gsd — what the marker MUST point at after install.
const CURRENT_SOURCE = path.join(REPO_ROOT, 'commands', 'gsd');
test('claude-global install overwrites a stale .gsd-source marker before staging reads it', (t) => {
const claudeDir = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
// Simulate the PREVIOUS install's marker: a different source dir that STILL EXISTS
// on disk (mirroring a coexisting npx per-version cache dir). findInstallSourceRoot
// returns this immediately if it is read before the marker is rewritten.
const staleSource = path.join(tmpRoot, 'old-npx-cache', 'commands', 'gsd');
fs.mkdirSync(staleSource, { recursive: true });
fs.writeFileSync(path.join(claudeDir, '.gsd-source'), staleSource + '\n', 'utf8');
// Spy on findInstallSourceRoot to capture WHICH source staging actually resolves.
// The marker ends up correct either way (the late write corrected it on the old code),
// so the marker content alone cannot prove the ordering — the resolved-source captures
// can. Before the fix, staging resolves the stale path; after, the current path.
// install-engine.cjs calls runtimeArtifactLayout.findInstallSourceRoot via the module
// object (not a captured ref), so mocking the property on the shared module instance
// intercepts the staging call. Same pattern as tests/phase.test.cjs (capture original,
// delegate).
const runtimeArtifactLayout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
const realFindInstallSourceRoot = runtimeArtifactLayout.findInstallSourceRoot;
const resolvedSources = [];
t.mock.method(runtimeArtifactLayout, 'findInstallSourceRoot', function (configDir) {
const result = realFindInstallSourceRoot.call(this, configDir);
resolvedSources.push(path.resolve(result));
return result;
});
runInstall(t, true /* isGlobal */, 'claude');
const markerPath = path.join(claudeDir, '.gsd-source');
assert.ok(fs.existsSync(markerPath), 'marker must exist after install');
const finalMarker = path.resolve(fs.readFileSync(markerPath, 'utf8').trim());
assert.equal(finalMarker, path.resolve(CURRENT_SOURCE),
`final marker must point at the current package source, not ${finalMarker}`);
// The decisive assertion: staging must NEVER have resolved the stale source. Before the
// fix, at least one staging resolution returned the stale path (read before rewrite).
const resolvedStale = resolvedSources.filter((p) => p === path.resolve(staleSource));
assert.equal(resolvedStale.length, 0,
`staging must not resolve the stale source during install, but did ${resolvedStale.length} time(s). ` +
`Resolved: ${JSON.stringify(resolvedSources)}`);
});
test('fresh claude-global install (no prior marker) still writes the correct marker', (t) => {
const claudeDir = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
// No pre-existing marker — fresh install (negative space, must stay correct).
runInstall(t, true /* isGlobal */, 'claude');
const markerPath = path.join(claudeDir, '.gsd-source');
assert.ok(fs.existsSync(markerPath), 'fresh claude-global install must write the marker');
const resolved = fs.readFileSync(markerPath, 'utf8').trim();
assert.equal(
path.resolve(resolved),
path.resolve(CURRENT_SOURCE),
'fresh install marker must point at the current package source',
);
});
test('claude-global install rewrites a marker pointing at a deleted (ghost) source', (t) => {
const claudeDir = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
// A ghost path that does NOT exist on disk — findInstallSourceRoot ignores it and
// falls through to walk-up, then install() rewrites the marker to the current source.
const ghost = path.join(tmpRoot, 'gone', 'commands', 'gsd');
fs.writeFileSync(path.join(claudeDir, '.gsd-source'), ghost + '\n', 'utf8');
runInstall(t, true /* isGlobal */, 'claude');
const markerPath = path.join(claudeDir, '.gsd-source');
assert.ok(fs.existsSync(markerPath), 'marker must exist after install');
const resolved = fs.readFileSync(markerPath, 'utf8').trim();
assert.equal(
path.resolve(resolved),
path.resolve(CURRENT_SOURCE),
'ghost marker must be rewritten to the current package source',
);
});
});