fix: pre-landing review fixes (base manifest sets the form, flag validated first, stamp_paths test)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-28 20:29:36 +00:00
1 parent 7459bb4831
commit 9bd98cb649
5 files changed
+34 -12

No files matched your search

+3 -3
View File
@@ -5,11 +5,11 @@
**`gstack-restamp` no longer cuts a four-part package.json version to three parts in a repo whose convention is four.** On garrytan/gbrain#7878, `/ship` restamped gbrain's `package.json` to `"0.60.483"` beside `VERSION` `0.60.483.0`; gbrain requires the two to match and its CI version gate fails when they drift, so the stamp had to be reverted by hand. The version stage called `gstack-version-bump write`, which always stores the npm-valid three-part form in the manifest (decision 11, which is why gstack's own `package.json` reads `1.91.298`).
### Fixed
- **`lib/restamp.ts`**: the version stage keeps the full version in the manifest when the policy lists it in `stamp_paths` or `stamp_files` and it already holds four parts, and the stage detail says so (`wrote 0.60.483.0 (package.json keeps four parts: …)`). Every other repo, gstack included, still gets the npm-valid form, and a four-part manifest the policy does not list still migrates to three parts as decision 11 says.
- **`bin/gstack-version-bump write`**: new `--manifest-form npm|full` (default `npm`); `full` writes the version as given to the manifest and its npm lockfiles.
- **`lib/restamp.ts`**: the version stage keeps the full version in the manifest when the policy lists it in `stamp_paths` or `stamp_files` and the base's copy holds four parts, and the stage detail says so (`wrote 0.60.483.0 (package.json keeps four parts: …)`). The form is read at the base, like the policy, so a branch an earlier restamp already cut to three parts goes back to four. Every other repo, gstack included, still gets the npm-valid form, and a four-part manifest the policy does not list still migrates to three parts as decision 11 says.
- **`bin/gstack-version-bump write`**: new `--manifest-form npm|full` (default `npm`); `full` writes the version as given to the manifest and its npm lockfiles. It has no effect when the version path is itself a JSON manifest.
### Tests
- `test/restamp-four-part-manifest.test.ts`: a gbrain-shaped fixture (four-part `VERSION` and `package.json`, the manifest in `stamp_files`, bun, no npm lockfile) restamped from an unstamped branch and from a branch already carrying its stamp; both failed before the fix with `"0.60.483"`. gstack's shape (three-part manifest and lockfile) and an unlisted four-part manifest keep their current results.
- `test/restamp-four-part-manifest.test.ts`: a gbrain-shaped fixture (four-part `VERSION` and `package.json`, the manifest in `stamp_files`, bun, no npm lockfile) restamped from an unstamped branch and from a branch already carrying its stamp (both failed before the fix with `"0.60.483"`), from a branch an older restamp cut to three parts, and with the manifest listed through `stamp_paths`. gstack's shape (three-part manifest and lockfile) and an unlisted four-part manifest keep their current results.
- `test/gstack-version-bump.test.ts`: `--manifest-form full` keeps four parts in the manifest and lockfile; an unknown form exits 2 before anything is written.
## [1.91.298.0] - 2026-10-28
+4 -3
View File
@@ -31,7 +31,8 @@
// 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.
// manifest as a stamp file and the base's copy holds four parts. No
// effect when the version path is itself a JSON manifest.
// --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
@@ -460,6 +461,8 @@ function cmdWrite(args: string[], cwd: string): void {
if (!VERSION_RE.test(version!)) {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH[.MICRO]. Aborting.`, 2);
}
const manifestForm = argVal(args, "--manifest-form") ?? "npm";
if (manifestForm !== "npm" && manifestForm !== "full") fail("write: --manifest-form needs npm|full", 2);
const src = versionSource(cwd, argVal(args, "--version-path"));
if (src.outcome === "broken") failBroken(src, "write");
if (src.outcome !== "valid") {
@@ -523,8 +526,6 @@ 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!);
+1 -1
View File
@@ -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. 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.
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 its copy at the base 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.
+6 -5
View File
@@ -225,15 +225,16 @@ export function versionFiles(root: string, versionRel: string): string[] {
}
/**
* 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
* stamp file and the base's copy holds a four-part version (gbrain). Read at the base, like the
* policy, so a branch an older restamp cut to three parts heals. 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;
const atBase = git(root, ['show', `${plan.baseSha}:${pkgRel}`]);
const version = atBase.ok ? extractVersion(atBase.out, pkgRel) : '';
return version && versionWidth(version) === 4 ? pkgRel : null;
}
function allowedOutput(plan: Plan, root: string, rel: string): boolean {
const p = plan.policy;
@@ -470,7 +471,7 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
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}` + (full ? ` (${full} keeps four parts: a policy stamp file that already holds four)` : '');
v.status = 'done'; v.detail = `wrote ${plan.newVersion}` + (full ? ` (${full} keeps four parts: a policy stamp file the base holds in four)` : '');
}
injected('version');
+20
View File
@@ -67,6 +67,26 @@ describe('gstack-restamp keeps the repo\'s manifest width', () => {
expect(pkgVersion(repo)).toBe('0.60.483.0');
});
test('a branch an older restamp cut to three parts heals: the base\'s four-part manifest sets the form', () => {
const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' });
fourPartManifest(repo, '0.60.482.0');
makePr(repo, 'feat-a');
setPkgVersion(repo, '0.60.482');
repo.git('commit', '-q', '-am', 'old restamp cut the manifest');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(pkgVersion(repo)).toBe('0.60.483.0');
});
test('a manifest listed through stamp_paths keeps four parts too', () => {
const repo = fixture({ policy: { ...GBRAIN_POLICY, stamp_files: [], stamp_paths: ['package.json'] }, 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(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');