diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 298fab17d4..72acc802f5 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -101,6 +101,35 @@ function writePkgVersion(cwd: string, version: string): void { writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n"); } +/** + * Opportunistically sync package-lock.json (lockfileVersion 3 stores the version + * in two places: top-level `version` and `packages[""].version`). Returns true if + * the lockfile was present and written, false if absent (yarn/pnpm/bun lockfile, + * or no JS at all). Throws on malformed JSON or write failure. + * + * Caller decides whether absence is an error — write() and repair() both treat + * absence as a silent skip. The state classifier intentionally does NOT inspect + * the lockfile: a stale lockfile root version doesn't break `npm ci` or anything + * runtime, so lockfile-only drift is opportunistically corrected on FRESH/repair + * but is not a halt condition. + */ +function writeLockVersion(cwd: string, version: string): boolean { + const lockPath = join(cwd, "package-lock.json"); + if (!existsSync(lockPath)) return false; + const raw = readFileSync(lockPath, "utf-8"); + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + fail("package-lock.json is not valid JSON. Fix the file before re-running /ship.", 2); + } + parsed.version = version; + const packages = parsed.packages as Record | undefined; + if (packages && packages[""]) packages[""].version = version; + writeFileSync(lockPath, JSON.stringify(parsed, null, 2) + "\n"); + return true; +} + function baseVersion(cwd: string, base: string, versionRel: string): string { // Verify the base ref resolves, mirroring the Step 12 guard. try { @@ -172,7 +201,18 @@ function cmdWrite(args: string[], cwd: string): void { ); } } - process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n"); + let lockfile = false; + try { + lockfile = writeLockVersion(cwd, version!); + } catch { + fail( + "failed to update package-lock.json. VERSION and package.json were written but the lockfile is now stale.", + 3, + ); + } + process.stdout.write( + JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")), packageLock: lockfile }) + "\n", + ); } function cmdRepair(args: string[], cwd: string): void { @@ -193,7 +233,13 @@ function cmdRepair(args: string[], cwd: string): void { } catch { fail("drift repair failed — could not update package.json.", 3); } - process.stdout.write(JSON.stringify({ repaired: current }) + "\n"); + let lockfile = false; + try { + lockfile = writeLockVersion(cwd, current); + } catch { + fail("drift repair failed — could not update package-lock.json.", 3); + } + process.stdout.write(JSON.stringify({ repaired: current, packageLock: lockfile }) + "\n"); } // Exported for unit tests (pure logic, no I/O). diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index ffcecd1a7f..62d2b268a0 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -54,7 +54,7 @@ describe('write (FRESH bump)', () => { fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0', scripts: { t: 'y' } }, null, 2) + '\n'); 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 }); + expect(JSON.parse(out)).toEqual({ wrote: '1.1.0.0', packageJson: true, packageLock: false }); 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')); expect(pkg.version).toBe('1.1.0.0'); @@ -72,7 +72,7 @@ describe('write (FRESH bump)', () => { const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-')); fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n'); 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 }); + expect(JSON.parse(out)).toEqual({ wrote: '0.2.0.0', packageJson: false, packageLock: false }); expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0'); fs.rmSync(d2, { recursive: true, force: true }); }); @@ -86,7 +86,7 @@ describe('repair (DRIFT_STALE_PKG)', () => { fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n'); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0.0' }, null, 2) + '\n'); const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString(); - expect(JSON.parse(out)).toEqual({ repaired: '2.0.0.0' }); + expect(JSON.parse(out)).toEqual({ repaired: '2.0.0.0', packageLock: false }); expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0.0'); expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('2.0.0.0'); // unchanged }); @@ -100,6 +100,54 @@ describe('repair (DRIFT_STALE_PKG)', () => { }); }); +describe('package-lock.json sync', () => { + // lockfileVersion 3 stores the version in two places: top-level `version` + // and packages[""].version. Both must move together or `npm ci` warns. + const mkLock = (dir: string, version: string) => + fs.writeFileSync( + path.join(dir, 'package-lock.json'), + JSON.stringify({ name: 'x', version, lockfileVersion: 3, packages: { '': { name: 'x', version } } }, null, 2) + '\n', + ); + + test('write syncs lockfile root + packages[""] version, reports packageLock: true', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-lock-write-')); + fs.writeFileSync(path.join(d, 'VERSION'), '1.0.0.0\n'); + fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0' }, null, 2) + '\n'); + mkLock(d, '1.0.0.0'); + const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d }).toString(); + expect(JSON.parse(out)).toEqual({ wrote: '1.1.0.0', packageJson: true, packageLock: true }); + const lock = JSON.parse(fs.readFileSync(path.join(d, 'package-lock.json'), 'utf-8')); + expect(lock.version).toBe('1.1.0.0'); + expect(lock.packages[''].version).toBe('1.1.0.0'); + fs.rmSync(d, { recursive: true, force: true }); + }); + + test('repair syncs lockfile up to VERSION, reports packageLock: true', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-lock-repair-')); + fs.writeFileSync(path.join(d, 'VERSION'), '2.0.0.0\n'); + fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0.0' }, null, 2) + '\n'); + mkLock(d, '1.9.0.0'); + const out = execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString(); + expect(JSON.parse(out)).toEqual({ repaired: '2.0.0.0', packageLock: true }); + const lock = JSON.parse(fs.readFileSync(path.join(d, 'package-lock.json'), 'utf-8')); + expect(lock.version).toBe('2.0.0.0'); + expect(lock.packages[''].version).toBe('2.0.0.0'); + fs.rmSync(d, { recursive: true, force: true }); + }); + + test('malformed package-lock.json fails write with exit 2', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-lock-bad-')); + fs.writeFileSync(path.join(d, 'VERSION'), '1.0.0.0\n'); + fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0' }, null, 2) + '\n'); + fs.writeFileSync(path.join(d, 'package-lock.json'), '{ not valid json'); + let code = 0; + try { execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d, stdio: 'pipe' }); } + catch (e: any) { code = e.status; } + expect(code).toBe(2); + fs.rmSync(d, { recursive: true, force: true }); + }); +}); + describe('classify (idempotency over a real git base)', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-classify-')); afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });