mirror of https://github.com/garrytan/gstack.git
fix(ship): sync package-lock.json version alongside package.json
/ship Step 12 (carved into bin/gstack-version-bump by #1806) bumps VERSION and package.json's version but leaves package-lock.json's version field stale. Every JS-repo ship then carries a spurious 2-line package-lock.json diff, and `npm ci` warns on the version mismatch. Same bug #1757 fixed against the old inline-bash surface; #1757 is now CONFLICTING since #1806 moved the code, so this re-ports the fix onto the CLI. writeLockVersion() mirrors writePkgVersion()'s semantics: - Silent skip when package-lock.json is absent (yarn/pnpm/bun/no JS). - Malformed lockfile -> exit 2, no corrupted write. - I/O failure after package.json already written -> exit 3 (half-write). - Updates both top-level `version` and packages[""].version (lockfileVersion 3 stores the root version in two places). Wired through both `write` (FRESH bump) and `repair` (DRIFT_STALE_PKG). The state classifier is intentionally untouched: a stale lockfile root version never blocks runtime, so it is opportunistically synced but is not a halt condition. Tests: 3 existing write/repair assertions updated for the new packageLock field; 3 new tests cover lockfile sync on write + repair plus the malformed-lockfile exit-2 guard. Full suite: 32 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c43c850cae
commit
ace89ac3e7
|
|
@ -101,6 +101,35 @@ function writePkgVersion(cwd: string, version: string): void {
|
||||||
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
|
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<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} 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<string, { version?: string }> | 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 {
|
function baseVersion(cwd: string, base: string, versionRel: string): string {
|
||||||
// Verify the base ref resolves, mirroring the Step 12 guard.
|
// Verify the base ref resolves, mirroring the Step 12 guard.
|
||||||
try {
|
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 {
|
function cmdRepair(args: string[], cwd: string): void {
|
||||||
|
|
@ -193,7 +233,13 @@ function cmdRepair(args: string[], cwd: string): void {
|
||||||
} catch {
|
} catch {
|
||||||
fail("drift repair failed — could not update package.json.", 3);
|
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).
|
// Exported for unit tests (pure logic, no I/O).
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ describe('write (FRESH bump)', () => {
|
||||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
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');
|
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();
|
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');
|
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'));
|
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
||||||
expect(pkg.version).toBe('1.1.0.0');
|
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-'));
|
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-'));
|
||||||
fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n');
|
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();
|
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');
|
expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0');
|
||||||
fs.rmSync(d2, { recursive: true, force: true });
|
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, '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');
|
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();
|
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(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
|
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)', () => {
|
describe('classify (idempotency over a real git base)', () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-classify-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-classify-'));
|
||||||
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
|
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue