mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 10:18:32 +01:00
fix(restamp): keep a four-part package.json version in repos that declare it
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
415e343b52
commit
810631a040
5 files changed
+133
-5
No files matched your search
+10
-2
@@ -25,8 +25,13 @@
|
||||
// the caller must handle), exit 2 on bad args / unresolvable base /
|
||||
// broken version source.
|
||||
//
|
||||
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest] [--changelog <p>]
|
||||
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest] [--changelog <p>] [--manifest-form npm|full]
|
||||
// Validates the 4-digit pattern, writes VERSION + package.json.version.
|
||||
// --manifest-form full: the manifest and its npm lockfiles carry the
|
||||
// version as written instead of the npm-valid translation (default
|
||||
// npm). For repos whose declared convention is a four-part manifest
|
||||
// (gbrain); gstack-restamp passes it only when the policy lists the
|
||||
// manifest as a stamp file and it already holds four parts.
|
||||
// --changelog <p> (opt-in): re-head the branch's `## [Unreleased]`
|
||||
// entry in <p> as `## [<version>] - <UTC date>` at the top, with
|
||||
// lib/changelog-check's reheadChangelog — the same function
|
||||
@@ -518,6 +523,8 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const manifestForm = argVal(args, "--manifest-form") ?? "npm";
|
||||
if (manifestForm !== "npm" && manifestForm !== "full") fail("write: --manifest-form needs npm|full", 2);
|
||||
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
|
||||
const hasPkg = existsSync(pkgPath);
|
||||
const changelogPlan = planChangelog(cwd, args, version!);
|
||||
@@ -528,7 +535,8 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
// 3-digit translation — npm rejects a fourth component, so mirroring the
|
||||
// raw 4-digit form breaks `npm ci` in any repo npm actually manages.
|
||||
// VERSION keeps the full 4-digit form; it stays the source of truth.
|
||||
const manifestV = npmVersion(version!);
|
||||
// --manifest-form full keeps a repo's declared four-part manifest as is.
|
||||
const manifestV = manifestForm === "full" ? version! : npmVersion(version!);
|
||||
if (hasPkg) {
|
||||
let pkgWritten = false;
|
||||
try {
|
||||
|
||||
+1
-1
@@ -995,7 +995,7 @@ or the order moved, the result is `QUEUE_STALE` (exit 3) with the exact
|
||||
Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
|
||||
|
||||
0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command).
|
||||
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
|
||||
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and it already holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so.
|
||||
2. **release_tool**: the policy command, under the contract below.
|
||||
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
|
||||
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
|
||||
+15
-2
@@ -223,6 +223,18 @@ export function versionFiles(root: string, versionRel: string): string[] {
|
||||
const dir = path.dirname(pkgRel) === '.' ? '' : path.dirname(pkgRel) + '/';
|
||||
return [versionRel, pkgRel, ...NPM_LOCKFILES.map(l => dir + l)];
|
||||
}
|
||||
/**
|
||||
* The manifest's path when the repo's convention is a four-part manifest: the policy lists it as a
|
||||
* stamp file and it already holds a four-part version (gbrain). Otherwise null, and the writer
|
||||
* stores the npm-valid three-part form (decision 11; gstack's own manifest).
|
||||
*/
|
||||
function manifestKeepsFourParts(plan: Plan, root: string): string | null {
|
||||
const pkgRel = versionFiles(root, plan.versionRel)[1]!;
|
||||
if (pkgRel === plan.versionRel || !matchesAny([...plan.policy.stamp_paths, ...plan.policy.stamp_files], pkgRel)) return null;
|
||||
const file = path.join(root, pkgRel);
|
||||
const current = fs.existsSync(file) ? extractVersion(fs.readFileSync(file, 'utf8'), pkgRel) : '';
|
||||
return current && versionWidth(current) === 4 ? pkgRel : null;
|
||||
}
|
||||
function allowedOutput(plan: Plan, root: string, rel: string): boolean {
|
||||
const p = plan.policy;
|
||||
if (versionFiles(root, plan.versionRel).includes(rel)) return true;
|
||||
@@ -455,9 +467,10 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
const v = stage('version');
|
||||
if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; }
|
||||
else {
|
||||
const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
const full = manifestKeepsFourParts(plan, staging);
|
||||
const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion, ...(full ? ['--manifest-form', 'full'] : [])], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); }
|
||||
v.status = 'done'; v.detail = `wrote ${plan.newVersion}`;
|
||||
v.status = 'done'; v.detail = `wrote ${plan.newVersion}` + (full ? ` (${full} keeps four parts: a policy stamp file that already holds four)` : '');
|
||||
}
|
||||
injected('version');
|
||||
|
||||
|
||||
@@ -84,6 +84,22 @@ describe('write (FRESH bump)', () => {
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
|
||||
test('--manifest-form full keeps a four-part manifest and its lockfile as written', () => {
|
||||
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-full-'));
|
||||
fs.writeFileSync(path.join(d2, 'VERSION'), '0.60.482.0\n');
|
||||
fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'g', version: '0.60.482.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(d2, 'package-lock.json'), JSON.stringify({ name: 'g', version: '0.60.482.0', lockfileVersion: 3, packages: { '': { version: '0.60.482.0' } } }, null, 2) + '\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.60.483.0', '--manifest-form', 'full'], { cwd: d2, timeout: 30_000 }).toString());
|
||||
expect(out.packageJsonVersion).toBe('0.60.483.0');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(d2, 'package.json'), 'utf-8')).version).toBe('0.60.483.0');
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(d2, 'package-lock.json'), 'utf-8'));
|
||||
expect([lock.version, lock.packages[''].version]).toEqual(['0.60.483.0', '0.60.483.0']);
|
||||
const bad = spawnSync('bun', [BIN, 'write', '--version', '0.60.484.0', '--manifest-form', 'wide'], { cwd: d2, timeout: 30_000 });
|
||||
expect(bad.status).toBe(2);
|
||||
expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.60.483.0');
|
||||
fs.rmSync(d2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('VERSION-only repo (no package.json) writes just VERSION', () => {
|
||||
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-'));
|
||||
fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n');
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* A four-part package.json version survives gstack-restamp in a repo whose
|
||||
* convention is a four-part manifest (gbrain: VERSION 0.60.483.0 and
|
||||
* package.json "0.60.483.0", cross-checked by its CI version gate). The
|
||||
* writer used to store the npm-valid three-part form there ("0.60.483").
|
||||
* gstack's own shape (a three-part manifest outside the policy's stamp files)
|
||||
* and the decision-11 migration of an unlisted four-part manifest are unchanged.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const GBRAIN_POLICY = {
|
||||
$schema: 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1',
|
||||
bump: 'patch',
|
||||
queue_mode: 'stamp-at-merge',
|
||||
changelog: 'CHANGELOG.md',
|
||||
stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'],
|
||||
stamp_patterns: ['"version": "{version}"'],
|
||||
};
|
||||
|
||||
const pkgVersion = (repo: FixtureRepo) => JSON.parse(readFile(repo, 'package.json')).version;
|
||||
const setPkgVersion = (repo: FixtureRepo, version: string) => {
|
||||
const pkg = JSON.parse(readFile(repo, 'package.json'));
|
||||
fs.writeFileSync(path.join(repo.work, 'package.json'), JSON.stringify({ ...pkg, version }, null, 2) + '\n');
|
||||
};
|
||||
|
||||
/** Rewrite the fixture's base as gbrain ships it: a four-part manifest mirroring VERSION, and bun, so no npm lockfile. */
|
||||
function fourPartManifest(repo: FixtureRepo, version: string): void {
|
||||
setPkgVersion(repo, version);
|
||||
fs.rmSync(path.join(repo.work, 'package-lock.json'));
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'four-part manifest');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
}
|
||||
|
||||
describe('gstack-restamp keeps the repo\'s manifest width', () => {
|
||||
test('gbrain-shaped repo: package.json keeps four parts and agrees with VERSION', () => {
|
||||
const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('STAMP_VERSION: 0.60.483.0');
|
||||
expect(r.out).toContain('stage version: done (wrote 0.60.483.0 (package.json keeps four parts');
|
||||
expect(readFile(repo, 'VERSION').trim()).toBe('0.60.483.0');
|
||||
expect(pkgVersion(repo)).toBe('0.60.483.0');
|
||||
});
|
||||
|
||||
test('gbrain-shaped branch that already carries its stamp: the restamp leaves the four-part manifest as is', () => {
|
||||
const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '0.60.483.0\n');
|
||||
setPkgVersion(repo, '0.60.483.0');
|
||||
repo.git('commit', '-q', '-am', 'stamp 0.60.483.0');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('STAMP_VERSION: 0.60.483.0');
|
||||
expect(r.out).not.toContain('diff --git a/package.json');
|
||||
expect(pkgVersion(repo)).toBe('0.60.483.0');
|
||||
});
|
||||
|
||||
test('gstack-shaped repo: the three-part manifest and its lockfile still get the npm-valid form', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'feat-a');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('stage version: done (wrote 1.2.4.0)');
|
||||
expect(readFile(repo, 'VERSION').trim()).toBe('1.2.4.0');
|
||||
expect(pkgVersion(repo)).toBe('1.2.4');
|
||||
const lock = JSON.parse(readFile(repo, 'package-lock.json'));
|
||||
expect([lock.version, lock.packages[''].version]).toEqual(['1.2.4', '1.2.4']);
|
||||
});
|
||||
|
||||
test('a four-part manifest the policy does not list as a stamp file still migrates to the npm-valid form (decision 11)', () => {
|
||||
const repo = fixture({ policy: { ...GBRAIN_POLICY, stamp_files: ['VERSION', 'CHANGELOG.md'] }, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('stage version: done (wrote 0.60.483.0)');
|
||||
expect(pkgVersion(repo)).toBe('0.60.483');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user