From 4b308f0261187c68bb7c9a13db0a312512bd7559 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 09:17:18 +0000 Subject: [PATCH] fix(restamp): a branch-added stamp left at the fork version is stale (GST-963) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gst-963-fork-version-stamp.md | 9 +++++++ docs/ship-policy.md | 2 +- lib/restamp.ts | 7 +++-- lib/ship-bin-common.ts | 8 +++--- test/restamp-mentions.test.ts | 31 ++++++++++++++++++++++- test/ship-rereview-triggers.test.ts | 5 ++-- 6 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 changelog.d/gst-963-fork-version-stamp.md diff --git a/changelog.d/gst-963-fork-version-stamp.md b/changelog.d/gst-963-fork-version-stamp.md new file mode 100644 index 000000000..88d70c23f --- /dev/null +++ b/changelog.d/gst-963-fork-version-stamp.md @@ -0,0 +1,9 @@ +**A version stamp the branch added at its fork point's version now counts as stale after the base moves (GST-963).** On gbrain #8425, a branch added a row that named the fork point's version. After main moved past it, `gstack-restamp` skipped the row ("no tentative mention") and `gstack-ship-release finish` routed the line as a behavior change, so the stamp kept the old version and the docs stage flagged a product edit. + +### Fixed +- **`lib/restamp.ts`:** a stamp_patterns match on a branch-added line that names the fork point's version is rewritten to the new version, like a mention of a newer tentative version. Lines the branch did not add, and versions below the fork point, are left alone. +- **`lib/ship-bin-common.ts`:** finish routes such a line as metadata (a stamp file), not behavior. + +### Tests +- `test/restamp-mentions.test.ts`: a branch-added row at the fork version after the base moved is restamped and its file is routed as metadata. On main, the stage reports `skipped` and ROUTE counts the file as behavior. +- `test/ship-rereview-triggers.test.ts`: the GST-895 fork-version case now routes as metadata. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 26f3ef71e..38ad2311e 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1523,7 +1523,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`: 1. **version**: `gstack-version-bump write --version ` 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 ``, 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/` 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 `` (`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 `, 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 (: 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 (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. 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. +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 `, 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 so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp once the base has moved past it; a branch-added mention older than the fork point stays), 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 (: 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 (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. 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. 5. **mirror**: tier 2; declared and validated, reported as not run. 6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage). diff --git a/lib/restamp.ts b/lib/restamp.ts index 06b4a8828..91afb0fcf 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -525,7 +525,9 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string /** * The stamp_paths stage. An explicit old version (the branch's own stamp, or * `--was`) is rewritten on every line of every stamp file; a tentative one - * (a mention newer than the fork version) only on lines the branch added. + * (a mention newer than the fork version, or of the fork version itself: a + * row written at the version the branch forked from instead of a next one, + * GST-963) only on lines the branch added. * With no explicit version the branch's VERSION equals the base's: either it * never stamped, or it stamped X and the base reached X on its own (a PR that * lost its slot and merged the base). Then a stamp-form mention of the base @@ -548,6 +550,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v))); const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null; if (equalBase) tentative.add(equalBase); + if (plan.forkVersion !== plan.newVersion && !explicit.has(plan.forkVersion)) tentative.add(plan.forkVersion); const moved = new Map(); const baseSide: string[] = []; let touched = 0; @@ -590,7 +593,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const baseOwnNote = baseOwn.length ? `; ${baseOwn.reduce((n, [, own]) => n + own.notOwn, 0)} line(s) the base added since the fork point are the base's, not this branch's, and stay (${baseOwn.map(([rel, own]) => `${rel}: ${own.notOwn}`).join('; ')})` : ''; sp.detail = (olds.length ? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}` - : `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote; + : `${searched}no tentative mention at or newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote; } function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] { diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 630a33ea1..0fd3aa119 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -10,7 +10,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; -import { branchAddedLines, forkVersion, parseUnifiedDiff, stampMentions, stampOnlyLine, versionAtRef } from './restamp'; +import { branchAddedLines, forkVersion, parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp'; import { matchesAny } from './ship-policy'; import { extractVersion } from './version-source'; import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation'; @@ -109,8 +109,8 @@ export function releasePaths(policy: Record | null, cwd: string, re * old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile). * A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule: * added since the merge base with origin/, and not added by the base since): after a lost slot and a merge of the - * base its stale stamp is older than `ref`'s VERSION, so it must be newer than the fork point's version instead, the - * bound gstack-restamp's stamp_paths stage uses (GST-895). + * base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead, + * the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). */ export function stampedFiles(policy: Record | null, cwd: string, ref: string, base: string): string[] { const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : []; @@ -140,7 +140,7 @@ export function stampedFiles(policy: Record | null, cwd: string, re if (stampOnlyLine(removed, added, before.version, after, templates)) return true; if (!ownLines(rel).has(removed)) return false; fork ??= forkVersion(cwd, `origin/${base}`, before.version); - return stampOnlyLine(removed, added, fork, after, templates) && (!stampMentions(removed, templates).has(fork) || stampMentions(added, templates).has(fork)); + return stampOnlyLine(removed, added, fork, after, templates); }; return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 && f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path); diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index a59c0e9c4..40a79fc21 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -157,7 +157,7 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(r.status).toBe(EXIT.ok); const sp = stageDetail(r.out, 'stamp_paths')!; expect(sp[1]).toBe('skipped'); - expect(sp[2]).toBe('no tentative mention newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)'); + expect(sp[2]).toBe('no tentative mention at or newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)'); expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"])); expect(r.out).not.toMatch(/^[-+] \{ since: '1\.2\.3\.0'/m); }); @@ -252,3 +252,32 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.5.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')])); }); }); + +describe('a branch-added row left at the fork version after the base moved (GST-963, GBRA-277, gbrain #8425)', () => { + // Value: protects=a stamp-form row the branch added at its fork version (not a tentative next version) is its own + // stale stamp once the base has moved past the fork, so --next moves it; the base's rows at that version, and a + // branch-added row older than the fork, stay; fails_when=the stage skips with "no tentative mention newer than the + // fork version" and leaves the row at the fork version (gbrain #8425), or moves a base row or a pre-fork row; + // seam=lib/restamp.ts rewriteStampPaths; no_claim=finish's route (test/ship-rereview-triggers.test.ts) + test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the fork-version row moves, the base rows and an older branch row stay', (_, templates) => { + const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-', policy: policy(templates) }); repos.push(repo); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')])); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-mine'); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.2.0', 'older'), '', row('1.2.3.0', 'mine')])); + repo.git('commit', '-qam', 'mine: a row at the fork version'); + makePr(repo, 'pr-other'); + write(repo, NOTICE, notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')])); + repo.git('commit', '-qam', 'other row'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-mine'); + mergeMain(repo); + expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(stageDetail(r.out, 'stamp_paths')![1]).toBe('done'); + expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', row('1.2.2.0', 'older'), '', row('1.2.5.0', 'mine')])); + }); +}); diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index cf28cc156..ee64d8363 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -206,7 +206,8 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => // Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older // than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the // base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row - // at or older than the fork point, moved the same way still stops; + // older than the fork point, moved the same way still stops; a branch row at the fork version is the branch's own + // stale stamp and routes as metadata (GST-963, gbrain #8425); // fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or // drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] }); @@ -239,7 +240,7 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => const atFork = lostSlot('1.0.0.0'); atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version'); const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]); - expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]); + expect([lineOf(a.stdout, 'ROUTE'), lineOf(a.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]); const edited = lostSlot('1.0.1.0'); edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin');