diff --git a/bin/gstack-coauthor-trailers b/bin/gstack-coauthor-trailers index 74beb65ab..6250b78ed 100755 --- a/bin/gstack-coauthor-trailers +++ b/bin/gstack-coauthor-trailers @@ -80,8 +80,10 @@ function main(argv: string[]): number { if (appended.length) { // git places the lines in the message's trailer block (joining an existing block, above comment // lines, CRLF kept), so an existing co-author keeps GitHub's attribution; a separate paragraph would not. + // Placement is pinned on the command line: a `---` body line is not a divider, and the user's trailer.* + // config cannot replace or drop the existing lines. if (!fs.existsSync(message)) fs.writeFileSync(message, ''); - const placed = git(['interpret-trailers', '--in-place', ...appended.flatMap(t => ['--trailer', t]), message]); + const placed = git(['-c', 'trailer.separators=:', 'interpret-trailers', '--in-place', '--no-divider', '--where', 'end', '--if-exists', 'addIfDifferentNeighbor', '--if-missing', 'add', ...appended.flatMap(t => ['--trailer', t]), '--', message]); if (!placed.ok) { process.stderr.write(`gstack-coauthor-trailers: cannot append to ${message}: ${placed.err.split('\n')[0] ?? ''}\n`); return EXIT.fail; } } } diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index a5cf1a0d8..4710c5d1f 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -162,8 +162,8 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0'; // Each constant is the release that first reads its key: a policy declaring the key must name at least // that version as min_gstack_version, so an older gstack refuses the policy by name instead of failing on // an unknown key. review_scope shipped in 1.91.256.0 (#3481). docs.caps is unreleased: its constant names the -// next release after the newest CHANGELOG heading, and test/gstack-ship-policy.test.ts fails when a release that -// does not read the key is stamped under that number (the stamper then moves the constant with the heading). +// release expected to stamp it (test/gstack-ship-policy.test.ts holds it newer than every CHANGELOG heading the +// checkout sees); whoever stamps that release under another number moves the constant to the stamped version. export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0'; export const DOCS_CAPS_KEY_VERSION = '1.91.258.0'; export const POLICY_KEY_INTRODUCED: Record = { diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 7251ea64e..ec9f4f76e 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -75,18 +75,20 @@ describe('validatePolicy (schema + containment, pure)', () => { }); describe('policy key versions against the CHANGELOG', () => { - test('an unreleased key names the release right after the newest heading; a released key has a heading', () => { - // The base branch's CHANGELOG counts too: a stamp-at-merge branch carries main's headings only after the slot merge. + test('an unreleased key names a release newer than every heading the checkout can see', () => { + // The base branch's CHANGELOG counts when the checkout has it (a depth-1 CI clone does not): a stamp-at-merge + // branch carries main's headings only after the slot merge, and a release stamped under another number + // leaves the constant naming a release that never read the key, which the stamper moves by hand. const root = path.join(import.meta.dir, '..'); const base = spawnSync('git', ['show', 'origin/main:CHANGELOG.md'], { cwd: root, encoding: 'utf8', timeout: 30_000 }); - const text = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8') + (base.stdout ?? ''); - const headings = [...text.matchAll(/^## \[(\d+\.\d+\.\d+\.\d+)\]/gm)].map(m => m[1]!.split('.').map(Number)); - const newest = headings.sort((a, b) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]! || a[3]! - b[3]!).at(-1)!; - const next = `${newest[0]}.${newest[1]}.${newest[2]! + 1}.0`; - const released = new Set(headings.map(h => h.join('.'))); + const text = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8') + (base.status === 0 ? base.stdout : ''); + const parse = (v: string) => v.split('.').map(Number); + const cmp = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]! || a[3]! - b[3]!; + const headings = [...text.matchAll(/^## \[(\d+\.\d+\.\d+\.\d+)\]/gm)].map(m => m[1]!); + const newest = headings.map(parse).sort(cmp).at(-1)!; for (const [key, version] of Object.entries(POLICY_KEY_INTRODUCED)) { - if (released.has(version)) continue; - expect(version, `${key} is introduced in ${version}, which has no CHANGELOG heading; an unreleased key names the next release ${next} (move it when the stamp lands elsewhere)`).toBe(next); + if (headings.includes(version)) continue; + expect(cmp(parse(version), newest), `${key} is introduced in ${version}, which has no CHANGELOG heading and is not newer than the newest heading ${newest.join('.')}; an unreleased key names the release that will stamp it`).toBeGreaterThan(0); } }); });