mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
fix(release): version-bump write regenerates the version-stamped agents digest
agents-digest/gstack-AGENTS.md embeds VERSION in its first line and is byte-freshness-gated (test/agents-digest.test.ts + Skill Docs Freshness CI), but nothing in the release path regenerated it — every version-bumping ship of this repo would land red. write now spawns the repo's own generator when present (agentsDigest true/false/null in the output JSON), and ship's evidence gate allow-lists the digest alongside VERSION/package.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
87e64f69ba
commit
e40faaea75
+35
-3
@@ -31,9 +31,13 @@
|
||||
// file. No bump. Validates the VERSION pattern first.
|
||||
//
|
||||
// Contract: classify NEVER writes. write/repair mutate VERSION + the manifest
|
||||
// + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present)
|
||||
// only. No git mutation, no network. Mirrors gstack-next-version's
|
||||
// reader/writer split so /ship composes them.
|
||||
// + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present) —
|
||||
// plus, in the gstack repo only, the committed agents digest (whose first
|
||||
// line embeds VERSION and is byte-freshness-gated by test/agents-digest.test.ts
|
||||
// and the Skill Docs Freshness CI check, so the write that changes VERSION
|
||||
// must regenerate it or every release commit goes red). No git mutation, no
|
||||
// network. Mirrors gstack-next-version's reader/writer split so /ship
|
||||
// composes them.
|
||||
//
|
||||
// Manifest resolution (all three subcommands accept --package-json-path):
|
||||
// --package-json-path <p> → .gstack/package-json-path → ./package.json
|
||||
@@ -335,6 +339,31 @@ function cmdClassify(args: string[], cwd: string): void {
|
||||
// the only exit-2 cases.)
|
||||
}
|
||||
|
||||
/**
|
||||
* gstack-repo-only side effect: agents-digest/gstack-AGENTS.md embeds VERSION
|
||||
* in its first line and is freshness-gated (committed bytes must equal the
|
||||
* generated bytes), so the bump that changes VERSION regenerates it in the
|
||||
* same write. Any repo without the generator + committed digest skips this.
|
||||
* Regen failure warns rather than failing the bump — the deterministic
|
||||
* freshness test stays red until it's rerun, so nothing rots silently.
|
||||
*/
|
||||
function regenAgentsDigest(versionPath: string): boolean | null {
|
||||
const root = dirname(versionPath);
|
||||
const gen = join(root, "scripts", "gen-agents-digest.ts");
|
||||
if (!existsSync(gen) || !existsSync(join(root, "agents-digest", "gstack-AGENTS.md"))) return null;
|
||||
try {
|
||||
execFileSync("bun", [gen], { cwd: root, stdio: "pipe", timeout: 30_000 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`write: VERSION updated but the agents digest regen failed (${msg.slice(0, 200)}). ` +
|
||||
"Run 'bun scripts/gen-agents-digest.ts' manually — test/agents-digest.test.ts stays red until then.\n",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cmdWrite(args: string[], cwd: string): void {
|
||||
const version = argVal(args, "--version");
|
||||
if (!version) fail("write requires --version <X.Y.Z.W>", 2);
|
||||
@@ -398,6 +427,7 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
|
||||
const hasPkg = existsSync(pkgPath);
|
||||
writeFileSync(versionPath, version + "\n");
|
||||
const digestRegen = regenAgentsDigest(versionPath);
|
||||
let lockSynced: string[] = [];
|
||||
// Decision 11: the manifest (and its lockfiles) carry the npm-valid
|
||||
// 3-digit translation — npm rejects a fourth component, so mirroring the
|
||||
@@ -432,6 +462,8 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null,
|
||||
packageJsonVersion: hasPkg ? manifestV : null,
|
||||
packageLock: lockSynced.length > 0,
|
||||
// null = not the gstack repo (no digest to regen); true/false = regen outcome
|
||||
agentsDigest: digestRegen,
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
+5
-3
@@ -898,15 +898,17 @@ EOF
|
||||
The evidence ledger is the mechanical arm of this law. Check it FIRST:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '<exact tests-lane command from Step 5>' --label vitest --expect-cmd '<exact vitest-lane command from Step 5>' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json
|
||||
~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '<exact tests-lane command from Step 5>' --label vitest --expect-cmd '<exact vitest-lane command from Step 5>' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md
|
||||
```
|
||||
|
||||
Pass each `--expect-cmd` the exact command string the wrapped Step 5 lane ran —
|
||||
that binds FRESH to the real suite (a green `echo ok` recorded under the label
|
||||
can never satisfy the check). Residual risk, accepted: `package.json` sits on
|
||||
the allow-list because Step 12's version bump writes its version field between
|
||||
the test run and this gate; a behavior-changing package.json edit in that
|
||||
window would not invalidate evidence. The check is advisory either way.
|
||||
the test run and this gate (and, in the gstack repo, regenerates the
|
||||
version-stamped `agents-digest/gstack-AGENTS.md`); a behavior-changing
|
||||
package.json edit in that window would not invalidate evidence. The check is
|
||||
advisory either way.
|
||||
|
||||
- **Every line FRESH (exit 0):** the recorded runs were green and the working-tree
|
||||
content is identical to what was tested, modulo the allow-listed release files
|
||||
|
||||
+5
-3
@@ -378,15 +378,17 @@ EOF
|
||||
The evidence ledger is the mechanical arm of this law. Check it FIRST:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '<exact tests-lane command from Step 5>' --label vitest --expect-cmd '<exact vitest-lane command from Step 5>' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json
|
||||
~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '<exact tests-lane command from Step 5>' --label vitest --expect-cmd '<exact vitest-lane command from Step 5>' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md
|
||||
```
|
||||
|
||||
Pass each `--expect-cmd` the exact command string the wrapped Step 5 lane ran —
|
||||
that binds FRESH to the real suite (a green `echo ok` recorded under the label
|
||||
can never satisfy the check). Residual risk, accepted: `package.json` sits on
|
||||
the allow-list because Step 12's version bump writes its version field between
|
||||
the test run and this gate; a behavior-changing package.json edit in that
|
||||
window would not invalidate evidence. The check is advisory either way.
|
||||
the test run and this gate (and, in the gstack repo, regenerates the
|
||||
version-stamped `agents-digest/gstack-AGENTS.md`); a behavior-changing
|
||||
package.json edit in that window would not invalidate evidence. The check is
|
||||
advisory either way.
|
||||
|
||||
- **Every line FRESH (exit 0):** the recorded runs were green and the working-tree
|
||||
content is identical to what was tested, modulo the allow-listed release files
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('write (FRESH bump)', () => {
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString();
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
|
||||
packageJsonVersion: '1.1.0', packageLock: false,
|
||||
packageJsonVersion: '1.1.0', packageLock: false, agentsDigest: null,
|
||||
});
|
||||
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('1.1.0.0');
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
||||
@@ -87,7 +87,7 @@ describe('write (FRESH bump)', () => {
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d2 }).toString();
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
wrote: '0.2.0.0', packageJson: false, packageJsonPath: null,
|
||||
packageJsonVersion: null, packageLock: false,
|
||||
packageJsonVersion: null, packageLock: false, agentsDigest: null,
|
||||
});
|
||||
expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0');
|
||||
fs.rmSync(d2, { recursive: true, force: true });
|
||||
@@ -134,7 +134,7 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => {
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString();
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
|
||||
packageJsonVersion: '1.1.0', packageLock: true,
|
||||
packageJsonVersion: '1.1.0', packageLock: true, agentsDigest: null,
|
||||
});
|
||||
const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8'));
|
||||
expect(l.version).toBe('1.1.0');
|
||||
@@ -756,3 +756,57 @@ describe('#2600: classify must surface versionFileExists=false when VERSION is m
|
||||
expect(result.state).toBe('ALREADY_BUMPED'); // base is 0.0.0.0, current is 0.2.0.0, pkg in sync
|
||||
});
|
||||
});
|
||||
|
||||
describe('write regenerates the gstack agents digest (gstack repo only)', () => {
|
||||
// The committed agents-digest/gstack-AGENTS.md embeds VERSION in its first
|
||||
// line and is byte-freshness-gated (test/agents-digest.test.ts + the Skill
|
||||
// Docs Freshness CI check). The write that changes VERSION must regenerate
|
||||
// it in the same mutation or every release commit of THIS repo goes red.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-'));
|
||||
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
|
||||
|
||||
test('a repo with the generator + committed digest gets a fresh digest on write', () => {
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'agents-digest'), { recursive: true });
|
||||
// Stub generator with the same shape as scripts/gen-agents-digest.ts:
|
||||
// read VERSION, write the version-stamped digest.
|
||||
fs.writeFileSync(path.join(dir, 'scripts', 'gen-agents-digest.ts'), [
|
||||
"import * as fs from 'fs';",
|
||||
"import * as path from 'path';",
|
||||
"const root = path.resolve(import.meta.dir, '..');",
|
||||
"const v = fs.readFileSync(path.join(root, 'VERSION'), 'utf-8').trim();",
|
||||
"fs.writeFileSync(path.join(root, 'agents-digest', 'gstack-AGENTS.md'), `# gstack digest v${v}\\n`);",
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), '# gstack digest v1.0.0.0\n');
|
||||
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString());
|
||||
expect(out.agentsDigest).toBe(true);
|
||||
expect(fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8'))
|
||||
.toBe('# gstack digest v1.1.0.0\n');
|
||||
});
|
||||
|
||||
test('a generator failure warns and reports agentsDigest:false without failing the bump', () => {
|
||||
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-fail-'));
|
||||
fs.writeFileSync(path.join(d2, 'VERSION'), '1.0.0.0\n');
|
||||
fs.mkdirSync(path.join(d2, 'scripts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(d2, 'agents-digest'), { recursive: true });
|
||||
fs.writeFileSync(path.join(d2, 'scripts', 'gen-agents-digest.ts'), 'process.exit(1);\n');
|
||||
fs.writeFileSync(path.join(d2, 'agents-digest', 'gstack-AGENTS.md'), '# gstack digest v1.0.0.0\n');
|
||||
|
||||
const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d2, stdio: 'pipe' });
|
||||
const out = JSON.parse(res.toString());
|
||||
expect(out.wrote).toBe('1.1.0.0'); // the bump itself still lands
|
||||
expect(out.agentsDigest).toBe(false);
|
||||
fs.rmSync(d2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('the REAL generator + real digest stay in lockstep through a bump round-trip', () => {
|
||||
// Belt and braces for the actual repo wiring (not a stub): bump a copy of
|
||||
// the real VERSION in place, regen, and confirm the first line tracks it.
|
||||
const root = path.join(import.meta.dir, '..');
|
||||
const committed = fs.readFileSync(path.join(root, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8');
|
||||
const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf-8').trim();
|
||||
expect(committed.split('\n')[0]).toContain(`v${version}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user