fix(ship-release): the changelog_fragments fragment is release metadata

Fix-check 4 (CRITICAL) after the main merge: releasePaths omitted
<changelog_fragments.dir>/<branch-slug>.md, so finish stopped on the
fragment Step 13 writes under gstack's policy, or routed it to re-review.
version now names the fragment in its Step 13 NEXT line instead of
[Unreleased], DRIVER.md Step 13 writes and lints where NEXT says, and the
preflight POLICY line says Steps 12-16.

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-30 22:15:44 +00:00
1 parent 72903d2883
commit 927c917b89
6 files changed
+42 -10

No files matched your search

+10 -1
View File
@@ -8,6 +8,7 @@
import { spawn, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fragmentSlug } from './changelog-fragments';
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
export interface Run { status: number | null; stdout: string; stderr: string }
@@ -93,8 +94,16 @@ export function versionOnlyEdit(cwd: string, ref: string, p: string): boolean {
*/
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string): string[] {
const list = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []);
const fragment = fragmentPath(policy, cwd);
return [...new Set(['VERSION', 'CHANGELOG.md', 'TODOS.md', 'agents-digest/gstack-AGENTS.md', ...VERSIONED_MANIFESTS.filter(p => versionOnlyEdit(cwd, ref, p)),
...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths)])];
...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths)])];
}
/** Under `changelog_fragments`, the branch's own Step 13 entry `<dir>/<branch-slug>.md` (docs/ship-policy.md#changelog-fragments); null without the key. */
export function fragmentPath(policy: Record<string, any> | null, cwd: string): string | null {
const dir = policy?.changelog_fragments?.dir;
if (typeof dir !== 'string') return null;
return `${dir.replace(/\/+$/, '')}/${fragmentSlug(git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim())}.md`;
}
/** The paths a lane receipt may ignore: release metadata except TODOS.md, whose Step 14 edit reruns the lanes (ship/SKILL.md Step 16). */
+1 -1
View File
@@ -92,7 +92,7 @@ function merge(cwd: string, base: string, r: Report): boolean {
r.add(`DISTRIBUTION: candidates=${short(candidates, 5)} release_workflow=${[...wf, ...(gitlab ? ['GITLAB_CI_RELEASE'] : [])].join(',') || 'none'}`)
.next('Step 2: read each candidate (and manifests for new binaries or exports); a new distributable without a pipeline needs Step 2\'s question');
}
r.add(`POLICY: ${['bump', 'queue_mode', 'review_scope', 'history'].map(k => `${k}=${policy?.[k] ?? '?'}`).join(' ')} (binds Steps 12, 16 and history)`);
r.add(`POLICY: ${['bump', 'queue_mode', 'review_scope', 'history'].map(k => `${k}=${policy?.[k] ?? '?'}`).join(' ')} (binds Steps 12-16 and history)`);
const plan = bin(cwd, 'gstack-review-plan', ['--base', base, '--json'], 180_000);
let runtime = 'unknown';
try {
+3 -2
View File
@@ -14,7 +14,7 @@ import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { resolveStateRoot } from './state-root';
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
import { bin, evidenceExempt, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short } from './ship-bin-common';
import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short } from './ship-bin-common';
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
@@ -60,7 +60,8 @@ export function version(argv: readonly string[], cwd: string): Report {
const base = flag('--base')!;
const r = new Report();
const policy = shipPolicy(cwd, base);
if (policy?.queue_mode === 'stamp-at-merge') return r.add('VERSION: skipped (policy: queue_mode stamp-at-merge replaces Step 12 items 1-4; the merge stamps it)').next('Step 13: write the CHANGELOG entry under ## [Unreleased]');
const fragment = fragmentPath(policy, cwd);
if (policy?.queue_mode === 'stamp-at-merge' || fragment) return r.add(`VERSION: skipped (policy: ${fragment ? 'changelog_fragments' : 'queue_mode stamp-at-merge'} replaces Step 12 items 1-4; the merge stamps it)`).next(fragment ? `Step 13: write the entry as ${fragment} (entry text only, no heading), never an [Unreleased] section` : 'Step 13: write the CHANGELOG entry under ## [Unreleased]');
let c = classify(cwd, base);
if (c.error) return r.stop(`a configured version file is missing, empty, unreadable or malformed: ${c.error}; fix it or its pin, then rerun (never substitute 0.0.0.0)`);
if (c.state === 'DRIFT_STALE_PKG') {
+3 -3
View File
@@ -102,9 +102,9 @@ question; `VERSION: skipped (policy: ...)`: write no version.
## 6. Notes and documentation: Steps 13, 14, 14.5
Step 13: one user-facing CHANGELOG entry from the commits file; `gstack-prose-lint CHANGELOG.md --base
origin/<base> --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and completions, edited from
the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
Step 13: one user-facing entry from the commits file, where `NEXT:` says;
`gstack-prose-lint <it> --base origin/<base> --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and
completions, edited from the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
one program (above), then `gstack-ship-receipt docs-caps --base <base>`; print `Documentation:`.
One re-audit at most.
+3 -3
View File
@@ -100,9 +100,9 @@ question; `VERSION: skipped (policy: ...)`: write no version.
## 6. Notes and documentation: Steps 13, 14, 14.5
Step 13: one user-facing CHANGELOG entry from the commits file; `gstack-prose-lint CHANGELOG.md --base
origin/<base> --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and completions, edited from
the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
Step 13: one user-facing entry from the commits file, where `NEXT:` says;
`gstack-prose-lint <it> --base origin/<base> --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and
completions, edited from the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
one program (above), then `gstack-ship-receipt docs-caps --base <base>`; print `Documentation:`.
One re-audit at most.
+22
View File
@@ -85,6 +85,28 @@ describe('finish (Steps 15-16)', () => {
expect(f.git('ls-remote', '--heads', 'origin', 'feat').stdout).toBe('');
});
// Value: protects=under changelog_fragments the branch's own fragment is release metadata: version writes nothing and names it in NEXT, finish commits it as the release commit and the route counts it as metadata, and another fragment is still a feature file; fails_when=finish stops on changelog.d/<slug>.md or routes it to re-review (/ship #3648 fix-check 4, CRITICAL);
// why_new=releasePaths predates changelog_fragments (#3654); seam=gstack-ship-policy show-effective, gstack-wtree; no_claim=the merge step's fold
test('under changelog_fragments the branch fragment is release metadata and Step 12 names it', () => {
const f = fx({ '.gstack/ship-policy.json': '{"changelog_fragments":{"dir":"changelog.d"},"min_gstack_version":"1.91.336.0"}\n', 'changelog.d/README.md': 'Fragments.\n' });
const reviewed = wtree(f);
const v = f.run(RELEASE, ['version', '--base', 'main']);
expect(v.status).toBe(0);
expect(lineOf(v.stdout, 'VERSION')).toContain('skipped (policy: changelog_fragments');
expect(v.stdout).toContain('NEXT: Step 13: write the entry as changelog.d/feat.md');
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
const post = snapshot(f);
const args = ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE];
f.write({ 'changelog.d/other.md': 'Not this branch.\n' });
expect(f.run(RELEASE, args).stdout).toContain('STOP: uncommitted changes outside release metadata: changelog.d/other.md');
fs.rmSync(path.join(f.work, 'changelog.d/other.md'));
f.write({ 'changelog.d/feat.md': 'One entry.\n' });
const ok = f.run(RELEASE, args);
expect(ok.stdout).toMatch(/^COMMIT: [0-9a-f]{12} chore: changelog and TODOS \(changelog\.d\/feat\.md\)/m);
expect(lineOf(ok.stdout, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
expect(ok.status).toBe(0);
});
// Value: protects=a behavior change after review inserts the re-review route, an authored doc change needs --docs-rechecked, stale docs and failing or absent lanes stop; fails_when=any of these continues to push;
// why_new=moved from Step 16 stages 2-4; seam=same; no_claim=the repair itself
test('behavior after review, unrechecked docs, stale docs, a failing lane and no lanes each stop', () => {