feat(ship-policy): changelog_fragments key; restamp folds fragments; tree receipt accepts the fold

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-29 08:41:57 +00:00
1 parent 16fd7a7dbd
commit 567e794cfe
6 files changed
+344 -6

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
/**
* changelog-fragments — unversioned CHANGELOG fragments (docs/ship-policy.md#changelog-fragments).
* Under the policy key `changelog_fragments: { dir }` a PR adds `<dir>/<slug>.md` instead of an
* `[Unreleased]` section and leaves its release-version fields at its merge base's values; the
* merge step folds every fragment into the CHANGELOG under the version it allocates and deletes the
* fragment files. Fragments are read as Git blobs, never through the filesystem: only regular files
* (mode 100644) directly under `dir` count, so a symlink, gitlink or nested path cannot pull bytes
* from outside the repository into a stamp.
*/
import { spawnSync } from 'node:child_process';
import { headingVersion, parseChangelog } from './changelog-check';
import type { Bump } from './version-source';
export const FRAGMENT_README = 'README.md';
export const MAX_FRAGMENT_BYTES = 64 * 1024;
const BUMP_RANK: Record<Bump, number> = { micro: 0, patch: 1, minor: 2, major: 3 };
export interface Fragment { path: string; body: string; bump: Bump | null }
export interface FragmentError { code: 'CHANGELOG_FRAGMENT_INVALID'; path: string; message: string }
export interface FragmentSet { fragments: Fragment[]; errors: FragmentError[] }
function git(cwd: string, args: string[]): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '' };
}
/** The fragment file name /ship writes for a branch: one flat path segment. */
export function fragmentSlug(branch: string): string {
const slug = branch.replace(/[\\/]+/g, '-').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 100);
return slug || 'change';
}
/** One fragment's text: optional `---` front matter holding only `bump:`, then a non-empty body with no release heading. */
export function parseFragment(rel: string, text: string): Fragment | FragmentError {
const bad = (message: string): FragmentError => ({ code: 'CHANGELOG_FRAGMENT_INVALID', path: rel, message });
const normalized = text.replace(/\r\n/g, '\n');
let body = normalized;
let bump: Bump | null = null;
const fm = /^---\n([\s\S]*?)\n---\n?/.exec(normalized);
if (fm) {
body = normalized.slice(fm[0].length);
for (const line of fm[1]!.split('\n').filter(l => l.trim())) {
const kv = /^\s*bump\s*:\s*['"]?(micro|patch|minor|major)['"]?\s*$/.exec(line);
if (!kv) return bad(`front matter may hold only "bump: micro|patch|minor|major", found ${JSON.stringify(line.trim().slice(0, 60))}`);
bump = kv[1] as Bump;
}
}
const lines = body.split('\n');
while (lines.length && !lines[0]!.trim()) lines.shift();
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop();
if (!lines.length) return bad('the fragment has no entry text');
const heading = lines.find(l => /^##\s/.test(l) && headingVersion(l));
if (heading) return bad(`a fragment carries no release heading (the merge step writes it), found ${JSON.stringify(heading.trim())}`);
return { path: rel, body: lines.join('\n'), bump };
}
/**
* The fragments in `dir` of a tree-ish (a commit or tree id), or of the index when `treeish` is
* null. `exclude` names a tree-ish whose fragments are not this change's own (the base branch, or a
* stacked pin's parent): a path present there with the same blob is skipped, a path present there
* with other bytes is an error.
*/
export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null): FragmentSet {
const prefix = dir.replace(/\/+$/, '') + '/';
const list = (t: string | null) => {
const r = t === null ? git(cwd, ['ls-files', '-s', '-z', '--', prefix]) : git(cwd, ['ls-tree', '-r', '-z', '--full-tree', t, '--', prefix]);
const out: Array<{ mode: string; sha: string; path: string }> = [];
for (const rec of r.out.split('\0').filter(Boolean)) {
const tab = rec.indexOf('\t');
const meta = rec.slice(0, tab).split(/\s+/);
out.push(t === null ? { mode: meta[0]!, sha: meta[1]!, path: rec.slice(tab + 1) } : { mode: meta[0]!, sha: meta[2]!, path: rec.slice(tab + 1) });
}
return out;
};
const excluded = new Map((exclude ? list(exclude) : []).map(e => [e.path, e.sha]));
const fragments: Fragment[] = [];
const errors: FragmentError[] = [];
for (const e of list(treeish).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
const rest = e.path.slice(prefix.length);
if (rest === FRAGMENT_README) continue;
const err = (message: string) => errors.push({ code: 'CHANGELOG_FRAGMENT_INVALID', path: e.path, message });
if (rest.includes('/')) { err(`fragments live directly in ${prefix} (one flat file each), not in a subdirectory`); continue; }
if (e.mode !== '100644') { err(`must be a regular file (mode 100644), found mode ${e.mode}${e.mode === '120000' ? ' (symlink)' : e.mode === '160000' ? ' (gitlink)' : ''}`); continue; }
if (!rest.endsWith('.md')) { err('a fragment is a .md file'); continue; }
const prior = excluded.get(e.path);
if (prior === e.sha) continue;
if (prior !== undefined) { err('a fragment of this name already exists on the base with other text; fragments are never edited after they land'); continue; }
const size = git(cwd, ['cat-file', '-s', e.sha]);
if (!size.ok || Number(size.out.trim()) > MAX_FRAGMENT_BYTES) { err(`larger than ${MAX_FRAGMENT_BYTES} bytes or unreadable`); continue; }
const blob = git(cwd, ['cat-file', 'blob', e.sha]);
if (!blob.ok) { err('blob unreadable'); continue; }
const parsed = parseFragment(e.path, blob.out);
if ('code' in parsed) errors.push(parsed); else fragments.push(parsed);
}
return { fragments, errors };
}
/** The highest of the policy bump and every fragment's declared bump. */
export function effectiveBump(level: Bump, fragments: readonly Fragment[]): Bump {
return fragments.reduce<Bump>((acc, f) => (f.bump && BUMP_RANK[f.bump] > BUMP_RANK[acc] ? f.bump : acc), level);
}
/**
* Put the fragment bodies, in path order, into the CHANGELOG's `[Unreleased]` section (created at the
* top when absent), so the existing re-heading moves fragments and a legacy entry together under one
* version. Returns the text unchanged when there are no fragments.
*/
export function foldIntoUnreleased(text: string, fragments: readonly Fragment[]): string {
if (!fragments.length) return text;
const parsed = parseChangelog(text);
const bodies = fragments.map(f => f.body).join('\n\n');
const unreleased = parsed.sections.find(s => s.unreleased);
const lines = text.replace(/\r\n/g, '\n').split('\n');
if (unreleased) {
const own = lines.slice(unreleased.start + 1, unreleased.end);
while (own.length && !own[0]!.trim()) own.shift();
while (own.length && !own[own.length - 1]!.trim()) own.pop();
const merged = [lines[unreleased.start]!, '', ...bodies.split('\n'), ...(own.length ? ['', ...own] : []), ''];
return [...lines.slice(0, unreleased.start), ...merged, ...lines.slice(unreleased.end)].join('\n');
}
const at = parsed.sections[0]?.start ?? lines.length;
const head = lines.slice(0, at);
while (head.length && !head[head.length - 1]!.trim()) head.pop();
return [...head, ...(head.length ? [''] : []), '## [Unreleased]', '', ...bodies.split('\n'), '', ...lines.slice(at)].join('\n');
}
+19 -3
View File
@@ -26,6 +26,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from './bun-isolation';
import { checkChangelog, mergeChangelogs, parseChangelog, reheadChangelog } from './changelog-check';
import { effectiveBump, foldIntoUnreleased, readFragments, type FragmentSet } from './changelog-fragments';
import { type ResultCodeName } from './result-codes';
import { runScriptSync, wtreeFingerprint } from './script-invocation';
import {
@@ -491,7 +492,11 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
if (clPath && fs.existsSync(clPath)) {
const date = (o.now ?? new Date()).toISOString().slice(0, 10);
const from = o.was ?? plan.branchStamp;
const r = reheadChangelog(fs.readFileSync(clPath, 'utf8'), { from, to: plan.newVersion, date, file: clRel! });
const dir = plan.policy.changelog_fragments?.dir;
const frags = dir ? readFragments(staging, null, dir, plan.baseSha) : null;
if (frags?.errors.length) { cl.status = 'failed'; cl.detail = `${frags.errors[0]!.path}: ${frags.errors[0]!.message}`; throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${cl.detail}; nothing written to the branch`); }
for (const f of frags?.fragments ?? []) fs.rmSync(path.join(staging, f.path));
const r = reheadChangelog(foldIntoUnreleased(fs.readFileSync(clPath, 'utf8'), frags?.fragments ?? []), { from, to: plan.newVersion, date, file: clRel! });
if (r.error) { cl.status = 'failed'; cl.detail = r.error.message; throw new Refusal(r.error.code, `${clRel}: ${r.error.message}; nothing written to the branch`); }
if (r.moved) fs.writeFileSync(clPath, r.text);
// Inherited duplicates are allowed at the larger count of two base readings: the base tip and
@@ -506,7 +511,7 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
: `; base ${clRel} unreadable at ${unreadable.join(', ')}, inherited headings counted at ${readable.map(x => x.sha.slice(0, 12)).join(', ')} only`;
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!, readable.map(x => x.cl.out + '\n'));
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message + baseNote; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}${baseNote}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + baseNote;
cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + (frags?.fragments.length ? `; folded ${frags.fragments.map(f => f.path).join(', ')}` : '') + baseNote;
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
injected('changelog');
@@ -646,6 +651,15 @@ function applyToBranch(o: RestampOptions, staging: string, wtree: string, files:
return writes;
}
/** The fragments this change adds (absent from origin/<base>), read from the working-tree fingerprint the staging worktree is seeded from. */
function fragmentsOf(o: RestampOptions, dir: string): FragmentSet {
const fp = wtreeFingerprint({ cwd: o.repoRoot });
if ('error' in fp) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${fp.error}); nothing written`);
const set = readFragments(o.repoRoot, fp.tree, dir, `origin/${o.base}`);
if (set.errors.length) throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${set.errors[0]!.path}: ${set.errors[0]!.message}; nothing written`);
return set;
}
function removeWorktree(repoRoot: string, dir: string): void {
git(repoRoot, ['worktree', 'remove', '--force', dir]);
fs.rmSync(dir, { recursive: true, force: true });
@@ -662,7 +676,9 @@ export function restamp(o: RestampOptions): RestampResult {
if (commands.length && !o.allowRepoCommands) {
throw new Refusal('REPO_COMMANDS_NOT_ALLOWED', `repo commands not executed (pass --allow-repo-commands): ${commands.map(c => `${c.key}: ${c.command}`).join('; ')}; nothing written`, { commands });
}
const plan = planVersions(o, loaded, label);
const fragDir = loaded.policy.changelog_fragments?.dir ?? null;
const frags = fragDir ? fragmentsOf(o, fragDir) : null;
const plan = planVersions(frags && !o.bump ? { ...o, bump: effectiveBump(loaded.policy.bump === 'patch' ? 'patch' : 'micro', frags.fragments) } : o, loaded, label);
const headSha = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const common: Partial<RestampResult> = {
oldVersion: plan.oldVersion, newVersion: plan.newVersion, base: { ref: plan.baseRef, sha: plan.baseSha }, policy: plan.policyLabel,
+26
View File
@@ -299,6 +299,32 @@ export const RELEASE_RESULT_CODES = {
summary: 'gstack-ship-receipt carry needs git 2.39 or later for `git patch-id --verbatim`; the installed git is older, so the product diff could not be compared',
fix: 'upgrade git to 2.39 or later (the refusal names the installed version), then rerun carry; otherwise run /ship on the new head',
},
// Changelog fragments and the per-PR merge step (lib/changelog-fragments.ts, lib/ship-queue-land.ts).
CHANGELOG_FRAGMENT_INVALID: {
anchor: 'changelog-fragment-invalid',
summary: 'a CHANGELOG fragment is not one regular .md file directly in the fragment directory with entry text, optional bump front matter and no release heading, or one of that name already landed with other text',
fix: 'rewrite the named fragment as a flat <dir>/<slug>.md with only the entry text (and optional "bump: minor|major" front matter), rename it if the name already landed, then rerun',
},
QUEUE_LAND_LOCKED: {
anchor: 'queue-land-locked',
summary: 'another gstack-ship-queue land holds the repository land lock (refs/notes/gstack-land), so this one would race it for the next version',
fix: 'wait for the holder named in the line to finish; if it crashed, gstack-ship-queue land --pr <n> --break-lock (refused while its journal shows a push in flight)',
},
QUEUE_LAND_CONFLICT: {
anchor: 'queue-land-conflict',
summary: 'the PR conflicts with the base branch in product files (or a release field cannot be resolved alone), so the merge step cannot stamp it',
fix: 'the lane merges the base branch into the PR, resolves the named files, runs /ship and reports READY again',
},
QUEUE_LAND_DRIFT: {
anchor: 'queue-land-drift',
summary: 'the base branch moved between the merge step\'s last check and the squash merge, so main\'s tree is not the stamped tree land verified',
fix: 'run the printed gstack-ship-queue land --repair to renumber main\'s top entry through a stamp-only PR; the land lock stays held with the record until it merges',
},
QUEUE_LAND_BASE_BUSY: {
anchor: 'queue-land-base-busy',
summary: 'the base branch moved during the stamp commit\'s CI on each of the allowed restamp attempts, so land stopped instead of restamping again',
fix: 'find what is merging outside land (every merge should go through it), then rerun gstack-ship-queue land --pr <n>; it resumes from its journal',
},
// gstack-pr-wait (lib/pr-wait.ts): the landing-path PR watcher's two terminal codes.
PR_WAIT_BLOCKED: {
anchor: 'pr-wait-blocked',
+15
View File
@@ -41,6 +41,8 @@ export interface ShipPolicy {
stamp_paths: string[];
/** The CHANGELOG re-headed on a restamp; null for repos without one. */
changelog: string | null;
/** Unversioned CHANGELOG fragments (`<dir>/<slug>.md`) a PR adds instead of an `[Unreleased]` section; null keeps `[Unreleased]`. */
changelog_fragments: { dir: string } | null;
/** Path of a paired repo to restamp identically (tier 2). */
mirror: string | null;
/** `merge-only` refuses a squash or rebase plan in /ship. */
@@ -102,6 +104,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
release_outputs: [],
stamp_paths: [],
changelog: 'CHANGELOG.md',
changelog_fragments: null,
mirror: null,
history: 'squash-ok',
preregistration_shas: [],
@@ -131,6 +134,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.',
stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.',
changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.',
changelog_fragments: '{ dir } or null: a PR adds one unversioned `<dir>/<slug>.md` fragment (optional `bump: minor|major` front matter) instead of an `[Unreleased]` section and leaves VERSION, the manifest version and released CHANGELOG sections at its merge base; the restamp folds every fragment under the allocated version and deletes the files (docs/ship-policy.md#changelog-fragments). null keeps `[Unreleased]`.',
mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.',
history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.',
preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.',
@@ -176,12 +180,14 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0';
// its key, so a value a stamp did not move fails there.
export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0';
export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.92.0.0';
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0',
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
};
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
@@ -360,6 +366,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' });
if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors);
if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors);
if ('changelog_fragments' in obj && obj.changelog_fragments !== null) fragmentsErrors(obj.changelog_fragments, errors);
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue;
@@ -441,6 +448,14 @@ function docsErrors(raw: unknown, errors: PolicyError[]): void {
});
}
function fragmentsErrors(raw: unknown, errors: PolicyError[]): void {
const d = raw as Record<string, unknown> | null;
if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir } or null' }); return; }
for (const k of Object.keys(d)) if (k !== 'dir') errors.push({ code: 'POLICY_INVALID', path: `changelog_fragments.${k}`, message: 'unknown key' });
const why = typeof d.dir === 'string' ? containedRelative(d.dir) ?? (/[*?[\]{}]/.test(d.dir) || d.dir.replace(/\/+$/, '') === '.' ? 'must be one directory, not a glob or the repository root' : null) : 'must be a string';
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: 'changelog_fragments.dir', message: `${JSON.stringify(d.dir)} ${why}` });
}
/** Merge a parsed file over the defaults, recording per-key provenance. */
export function mergePolicy(raw: Record<string, unknown> | null, sourceLabel: string): { policy: ShipPolicy; values: Record<string, string> } {
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
+21 -3
View File
@@ -14,6 +14,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { normalizeChangelog } from './changelog-check';
import { foldIntoUnreleased, parseFragment, type Fragment } from './changelog-fragments';
import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle';
import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
import type { ResultCodeName } from './result-codes';
@@ -71,7 +72,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string
return normalizeLockfile(name, a) === normalizeLockfile(name, b);
}
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict {
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = []): FileVerdict {
const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line });
const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' });
if (f.binary) return changed('binary', 'binary content differs');
@@ -98,7 +99,8 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
return changed('manifest', 'a field other than version changed');
}
if (policy.changelog && f.path === policy.changelog) {
return before !== null && after !== null && normalizeChangelog(before) === normalizeChangelog(after) ? stamp('changelog-heading') : changed('changelog', 'entry text changed (only a heading move or re-heading is a stamp)');
if (before !== null && after !== null && normalizeChangelog(foldIntoUnreleased(before, folded)) === normalizeChangelog(after)) return stamp(folded.length ? 'changelog-fragments' : 'changelog-heading');
return changed('changelog', folded.length ? 'entry text is not exactly the deleted fragments folded under the new heading' : 'entry text changed (only a heading move or re-heading is a stamp)');
}
const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null;
if (oldV && newV && oldV !== newV && rule) {
@@ -145,7 +147,23 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
const oldV = 'version' in gv ? gv.version : null;
const newV = 'version' in cv ? cv.version : null;
const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? '';
const files = parseUnifiedDiff(diffText).map(f => classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV));
const fileDiffs = parseUnifiedDiff(diffText);
const prefix = o.policy.changelog_fragments ? o.policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null;
const folded = new Map<string, Fragment>();
for (const f of fileDiffs) {
if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue;
const text = blob(o.repoRoot, gatedHead, f.path);
const parsed = text === null ? null : parseFragment(f.path, text);
if (parsed && !('code' in parsed)) folded.set(f.path, parsed);
}
const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
const changelogVerdict = (f: FileDiff) => classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV, fragments);
const clDiff = fileDiffs.find(f => o.policy.changelog && f.path === o.policy.changelog);
const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp';
const files = fileDiffs.map((f): FileVerdict => {
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV);
return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${o.policy.changelog ?? 'the CHANGELOG'}` };
});
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : '';
+138
View File
@@ -0,0 +1,138 @@
/**
* Changelog fragments (docs/ship-policy.md#changelog-fragments): the reader takes only regular .md
* blobs directly in the fragment directory, the restamp folds them under the allocated version with
* the highest declared bump and deletes them, and the tree receipt accepts exactly that fold as a stamp.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { effectiveBump, foldIntoUnreleased, fragmentSlug, parseFragment, readFragments } from '../lib/changelog-fragments';
import { reheadChangelog } from '../lib/changelog-check';
import { mergePolicy, validatePolicy } from '../lib/ship-policy';
import { buildReceipt } from '../lib/tree-receipt';
import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo({ policy: POLICY }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
function fragmentPr(repo: FixtureRepo, branch: string, fragments: Record<string, string>, extra: (work: string) => void = () => {}): string {
repo.git('checkout', '-q', 'origin/main', '-b', branch);
fs.writeFileSync(path.join(repo.work, `${branch}.txt`), `${branch}\n`);
fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true });
for (const [name, text] of Object.entries(fragments)) fs.writeFileSync(path.join(repo.work, 'changelog.d', name), text);
extra(repo.work);
repo.git('add', '-A');
repo.git('commit', '-q', '-m', `feat ${branch}`);
return repo.git('rev-parse', 'HEAD');
}
describe('parseFragment', () => {
test('body only, or bump front matter then a body', () => {
expect(parseFragment('changelog.d/a.md', '\n- **Faster slots.** text\n\n')).toEqual({ path: 'changelog.d/a.md', body: '- **Faster slots.** text', bump: null });
expect(parseFragment('changelog.d/a.md', '---\nbump: minor\n---\n### Added\n- x\n')).toEqual({ path: 'changelog.d/a.md', body: '### Added\n- x', bump: 'minor' });
});
test('refuses empty text, unknown front matter and a release heading', () => {
expect(parseFragment('a.md', '\n\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID', message: 'the fragment has no entry text' });
expect(parseFragment('a.md', '---\nversion: 1.2.3\n---\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' });
expect(parseFragment('a.md', '## [1.2.4.0] - 2026-10-29\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' });
expect(parseFragment('a.md', '## [Unreleased]\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' });
});
test('slug is one flat path segment', () => {
expect(fragmentSlug('gsta154/changelog-fragments')).toBe('gsta154-changelog-fragments');
expect(fragmentSlug('../..')).toBe('change');
});
});
describe('effectiveBump and the fold', () => {
test('the highest declared bump wins over the policy level', () => {
const f = (bump: any) => ({ path: 'x', body: 'b', bump });
expect(effectiveBump('patch', [f(null)])).toBe('patch');
expect(effectiveBump('patch', [f('minor'), f(null)])).toBe('minor');
expect(effectiveBump('micro', [f('patch'), f('major'), f('minor')])).toBe('major');
});
test('fragments go above a legacy [Unreleased] entry, and re-heading moves both under one version', () => {
const cl = '# Changelog\n\n## [Unreleased]\n\n- legacy\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n';
const folded = foldIntoUnreleased(cl, [{ path: 'changelog.d/a.md', body: '- a', bump: null }, { path: 'changelog.d/b.md', body: '- b', bump: null }]);
const r = reheadChangelog(folded, { from: null, to: '1.2.4.0', date: '2026-10-29' });
expect(r.text).toBe('# Changelog\n\n## [1.2.4.0] - 2026-10-29\n\n- a\n\n- b\n\n- legacy\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n');
});
test('no fragments leaves the text unchanged', () => {
expect(foldIntoUnreleased('# C\n', [])).toBe('# C\n');
});
});
describe('readFragments reads blobs only', () => {
test('a symlink, a nested file and a non-.md file refuse; README.md and base fragments are skipped', () => {
const repo = fixture();
const outside = path.join(repo.root, 'secret.txt');
fs.writeFileSync(outside, 'SENTINEL-OUTSIDE\n');
fragmentPr(repo, 'feat-a', { 'a.md': '- a\n', 'README.md': 'how to write fragments\n', 'notes.txt': 'x\n' }, work => {
fs.symlinkSync(outside, path.join(work, 'changelog.d', 'link.md'));
fs.mkdirSync(path.join(work, 'changelog.d', 'sub'));
fs.writeFileSync(path.join(work, 'changelog.d', 'sub', 'deep.md'), '- deep\n');
});
const set = readFragments(repo.work, 'HEAD', 'changelog.d', 'origin/main');
expect(set.fragments.map(f => f.path)).toEqual(['changelog.d/a.md']);
expect(set.errors.map(e => e.path).sort()).toEqual(['changelog.d/link.md', 'changelog.d/notes.txt', 'changelog.d/sub/deep.md']);
expect(set.errors.find(e => e.path === 'changelog.d/link.md')!.message).toContain('symlink');
expect(JSON.stringify(set)).not.toContain('SENTINEL-OUTSIDE');
});
});
describe('policy key', () => {
test('changelog_fragments needs one contained directory and min_gstack_version at its key version', () => {
expect(validatePolicy(POLICY)).toEqual([]);
expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: '../x' } }).map(e => e.path)).toContain('changelog_fragments.dir');
expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'changelog.d/*' } }).map(e => e.path)).toContain('changelog_fragments.dir');
expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', glob: '*' } }).map(e => e.path)).toContain('changelog_fragments.glob');
const { min_gstack_version: _drop, ...noMin } = POLICY;
expect(validatePolicy(noMin).map(e => e.path)).toContain('changelog_fragments');
});
});
describe('restamp folds fragments', () => {
test('a minor fragment under a patch policy stamps the next minor, folds, deletes the fragment, and the receipt calls it a stamp', () => {
const repo = fixture();
const pin = fragmentPr(repo, 'feat-a', { 'feat-a.md': '---\nbump: minor\n---\n- **Fragments.** feat-a\n' });
const r = restamp(repo, ['--next']);
expect(r.err).toBe('');
expect(r.out).toContain('STAMP_VERSION: 1.3.0.0');
expect(r.out).toContain('folded changelog.d/feat-a.md');
expect(fs.existsSync(path.join(repo.work, 'changelog.d', 'feat-a.md'))).toBe(false);
expect(readFile(repo, 'CHANGELOG.md')).toMatch(/^# Changelog\n\n## \[1\.3\.0\.0\] - \d{4}-\d{2}-\d{2}\n\n- \*\*Fragments\.\*\* feat-a\n\n## \[1\.2\.3\.0\]/);
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'stamp');
const receipt = buildReceipt({ repoRoot: repo.work, gated: pin, current: 'HEAD', policy: mergePolicy(POLICY, 'test').policy, policyLabel: 'test' });
if ('error' in receipt) throw new Error(receipt.error);
expect(receipt.residual).toEqual([]);
expect(receipt.files.find(f => f.path === 'changelog.d/feat-a.md')).toMatchObject({ rule: 'changelog_fragments', verdict: 'stamp' });
expect(receipt.files.find(f => f.path === 'CHANGELOG.md')).toMatchObject({ rule: 'changelog-fragments', verdict: 'stamp' });
});
test('an edited fold, or a fragment deleted without its text, is a change', () => {
const repo = fixture();
const pin = fragmentPr(repo, 'feat-b', { 'feat-b.md': '- feat-b\n' });
expect(restamp(repo, ['--next']).out).toContain('STAMP_VERSION: 1.2.4.0');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), readFile(repo, 'CHANGELOG.md').replace('- feat-b', '- feat-b, reworded'));
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'stamp plus edit');
const policy = mergePolicy(POLICY, 'test').policy;
const edited = buildReceipt({ repoRoot: repo.work, gated: pin, current: 'HEAD', policy, policyLabel: 'test' });
if ('error' in edited) throw new Error(edited.error);
expect(edited.residual.sort()).toEqual(['CHANGELOG.md', 'changelog.d/feat-b.md']);
repo.git('checkout', '-q', pin);
fs.rmSync(path.join(repo.work, 'changelog.d', 'feat-b.md'));
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'drop fragment only');
const dropped = buildReceipt({ repoRoot: repo.work, gated: pin, current: 'HEAD', policy, policyLabel: 'test' });
if ('error' in dropped) throw new Error(dropped.error);
expect(dropped.files.find(f => f.path === 'changelog.d/feat-b.md')).toMatchObject({ verdict: 'changed' });
});
test('an invalid fragment refuses before anything is written', () => {
const repo = fixture();
fragmentPr(repo, 'feat-c', { 'feat-c.md': '## [9.9.9.9]\n- no\n' });
const r = restamp(repo, ['--next']);
expect(r.err).toContain('(CHANGELOG_FRAGMENT_INVALID)');
expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n');
});
});