diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 056669aae..e257edd7e 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -675,6 +675,16 @@ function cmdCarry(rest: string[], json: boolean): number { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p); + if (cls === 'metadata' && spec.demoted.includes(p) && movedBase.has(p) && /(^|\/)package\.json$/.test(p)) { + const masked = Object.values(sides).map(ref => { + const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); + try { const { version: _, ...rest } = JSON.parse(r.status === 0 ? r.stdout : 'null') ?? {}; return JSON.stringify(rest); } catch { return null; } + }); + const [rb, rv, cb, cv] = masked; + if (masked.some(m => m === null) || rv !== cv || rb !== cb) changedProduct.push(p); + else stampOnly.push(p); + continue; + } if (cls !== 'product') continue; const verdict = comparePath(repoRoot, sides, p); if (verdict === 'changed') changedProduct.push(p); diff --git a/changelog.d/gst-1026-1030-stamp-placeholders.md b/changelog.d/gst-1026-1030-stamp-placeholders.md index 09a33dbc5..31ddbd11f 100644 --- a/changelog.d/gst-1026-1030-stamp-placeholders.md +++ b/changelog.d/gst-1026-1030-stamp-placeholders.md @@ -6,11 +6,12 @@ ### Fixed - **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. - **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded. +- **`bin/gstack-ship-receipt` carry, pin-bump PRs:** a `package.json` the branch changed as product (a dependency pin) that both main and a restamp moved since review is compared with only its `version` field masked, at all four sides. The receipt carries when nothing else in the file moved, and still refuses when the pin or any other field changed (GBEV-3, gbrain-evals #306). ### Tests - `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. - `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls. -- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product. +- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product; a pin-bump PR's receipt carries after a main merge and a restamp, and refuses when the pin moved since review. - `test/gstack-ship-policy.test.ts`: the key's default, floor and validation. The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it. diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index fed37e729..7bc14c5b2 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -541,6 +541,35 @@ describe('declared placeholders and the receipt carry (GST-1026, GST-1030)', () expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.5.0'); }); +/** package.json with its dependency pin set to `pin`, every other field kept. */ + const pinned = (repo: FixtureRepo, pin: string) => { + const pkg = JSON.parse(readFile(repo, 'package.json')); + pkg.dependencies = { ...pkg.dependencies, 'left-pad': pin }; + write(repo, 'package.json', JSON.stringify(pkg, null, 2) + '\n'); + }; + + // Value: protects=a pin-bump PR's receipt carries across a main merge and a restamp when package.json's only change since + // review is its version field, and refuses when the pin itself moved (GST-1030, GBEV-3, gbrain-evals #306); + // fails_when=the carry counts the PR's package.json as product and refuses RECEIPT_CARRY_PRODUCT_CHANGED on a version-only + // move, or lets a changed dependency pin ride; seam=bin/gstack-ship-receipt cmdCarry walk + test.each([['the pin unchanged since review', false], ['the pin moved since review', true]])('the carry and a pin-bump PR after a main merge: %s (GST-1030)', (_, moved) => { + const { repo, body } = receiptThenStamp(`restamp-carry-pin-${moved}-`, policy(true), r => pinned(r, '1.3.0')); + repo.git('reset', '-q', '--hard'); + makePr(repo, 'pr-other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-carry'); + mergeMain(repo); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + if (moved) pinned(repo, '1.3.1'); + repo.git('commit', '-qam', 'restamp after the lost slot'); + expect(JSON.parse(readFile(repo, 'package.json')).version).toBe('1.2.5'); + const carried = carry(repo, body); + if (moved) expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]); + else expect(carried.out, carried.err).toContain('CARRY: carried'); + }); + // Value: protects=a version-only restamp of EVIDENCE.md is release metadata to the carry when the policy declares it in // stamp_files, and product when it does not (GST-1030 point 2, GGST-1); fails_when=the carry counts the declared // file's stamp as a product change (RECEIPT_CARRY_PRODUCT_CHANGED), or lets an undeclared file's version edit ride;