Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions bin/gstack-version-bump
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 {
// Verify the base ref resolves, mirroring the Step 12 guard.
try {
Expand Down Expand Up @@ -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 {
Expand All @@ -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).
Expand Down
54 changes: 51 additions & 3 deletions test/gstack-version-bump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
});
Expand All @@ -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
});
Expand All @@ -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 */ } });
Expand Down