mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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> * feat(ship-queue): land, the per-PR merge step; Version Gate enforces fragments Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): land --repair after drift; hook base sha; docs and CHANGELOG Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: timeout on the land gh shim's git spawn Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): /ship writes a CHANGELOG fragment under changelog_fragments; land checks the depth-checked receipt (#3567) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(land): keeps the base's four-part package.json (#3557) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): land waits for CI through runScriptSync (script-invocation tripwire) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: land, fragment and Version Gate cases from /ship's coverage audit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): leave test/ship-queue-land.test.ts out of the Windows-safe subset (gh shim cannot launch there) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): rollback lands open fragment PRs first; restamp folds only with the key on Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(land): close pre-landing review findings land without --no-wait now compares the pushed branch head and polls the stamp commit's checks, refuses a PR whose base is not the stamped base, releases the lock on a pre-push failure, keeps --break-lock off drift locks, resumes from a lost journal via the stamp commit's pin, honours history merge-only, and refuses a fragment whose text is already released on the base. Version Gate stops applying open claims to a fragments stamp. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(land): close adversarial and outside-voice findings land reconciles a merge GitHub completed behind an error reply, refuses --break-lock against a holder that just entered a push or a merge, keeps a repair takeover on the lock itself (any checkout can land the repair PR), resumes --repair after a failed PR create, releases the lock on QUEUE_LAND_BASE_BUSY, never writes through a symlinked release file, keeps a package.json version source's other edits, and refuses an empty object id instead of pushing a deletion. A carried-back fragment is matched on whole released lines. Under changelog_fragments Version Gate accepts a stamped head only as a [pin, base] merge-step commit with valid fragments. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(land): refuse a pin without a READY receipt; symlinked release files refuse before restamp Under changelog_fragments, gstack-ship-queue ready records a READY receipt (refs/notes/gstack-trains) once its pregate, full_e2e and /ship receipt checks pass, and land refuses a pin without one (fix: run gstack-ship-queue ready --pr N), --dry-run included (GStack Minder ruling, 2026-10-29). The merged tree's release files may not be symlinks, so restamp never writes outside the scratch worktree. --repair adopts an existing branch only when it holds land's repair commit. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): clear's stamp-only substitution applies the changelog_fragments fold; docs match land and ready gstack-pregate clear and gstack-tree-receipt now classify through one classifyFiles, so under changelog_fragments a receipt for the fragment head clears the stamped head when the stamp is exactly the fold. The ship-policy reference names ready's receipt push, land's own check polling, the key-off refusal and the /ship changelog_fragments line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate, ship-policy): the fragment fold's two receipt rules, and clear applies them too Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): stamped train prefixes S_i with the self-check (PR B) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): train build stamps S_i under fragments; train land publishes S_i exactly; backstop covers exact trees (PR B) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: stamped trains and train land; QUEUE_TRAIN_STAMP_MISMATCH anchor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * captain templates: fragments instead of [Unreleased]; no slot call for stamped trains Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: timeouts on the train-land gh wrapper's sync spawns Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): train land checks the depth-checked receipt (#3567); regenerate the doctor anchors Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(train-stamp): S_i keeps the base's four-part package.json (#3557) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): leave test/ship-queue-train-land.test.ts out of the Windows-safe subset (same gh shim as the train tests) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): close pre-landing review and outside-voice findings - train land checks commit-tree and stamps with a fixed identity and date, journals before the push, and adopts its own stamp head on a rerun; a failed commit-tree never pushes a branch deletion - a symlinked release file in the merged tree refuses before the train stamp writes - mergeStep merges a conflicted manifest three-way with the predecessor's version (mergeManifest, shared with land) - train land refuses a PR retargeted off the base and a failed base refresh - Version Gate skips a released fragment carried in by a stacked pin instead of counting its bump - bisect of a stamped train tests T0 and S_1..S_k Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): a rerun whose head is still the pin re-enters the stamp step after a journaled, unpushed stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): prove the record's S_i is on the gated chain before stamping; a merge main does not show yet pends Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): pin S_i to the gated head's exact prefix; an out-of-order slot refuses without freezing; stamps never sign Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): pin S_i to slot position; a failed push or unreadable PR never reads as a move; reruns of landed slots confirm; T0 in the out-of-order refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(train-land): verify the whole stamped chain from git before landing; require changelog_fragments.required_checks green on the stamp commit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy, troubleshooting, harness): train land refusals, resume and required checks; stamped bisect; three-way manifest merges Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: write the Steps 12-16 range with an ASCII hyphen so the skeleton fits maxSkeletonBytes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
104 lines
7.4 KiB
TypeScript
104 lines
7.4 KiB
TypeScript
/**
|
|
* Stamped train prefixes (docs/ship-policy.md#run-a-merge-train, GSTA-154 PR B): under
|
|
* changelog_fragments a train is built twice. T_i is the plain chain buildChain makes (each pin merged
|
|
* onto the previous prefix, unversioned); S_i = stamp(merge(S_(i-1), pin_i)) is what slot i publishes,
|
|
* with S_0 = T_0 = the base. Each S_i is stamped by gstack-restamp with S_(i-1) as its base, so slot i
|
|
* folds only its own fragments (a stacked pin's parent fragments are already folded in S_(i-1)) and the
|
|
* versions run base+bump_1, +bump_2, ... in ORDER, every earlier release entry kept. Two checks run
|
|
* before anything is pushed: S_i is merge(S_(i-1), pin_i) modulo stamps (the tree receipt, which knows
|
|
* the fragment fold), and S_i equals T_i byte for byte outside release paths. Either failing is
|
|
* QUEUE_TRAIN_STAMP_MISMATCH.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { parseChangelog } from './changelog-check';
|
|
import { restamp, versionAtRef, versionFiles } from './restamp';
|
|
import { matchesAny, type ShipPolicy } from './ship-policy';
|
|
import { git, type QueueFailure } from './ship-queue-gate';
|
|
import { linkedReleaseFile } from './ship-queue-land';
|
|
import { mergeCommit } from './ship-queue-train';
|
|
import type { StampedPrefix, TrainPrefix, TrainSlot } from './ship-queue-train-record';
|
|
import { buildReceipt } from './tree-receipt';
|
|
|
|
export type { StampedPrefix };
|
|
export interface StampChainOptions {
|
|
repoRoot: string; base: string; policy: ShipPolicy; policyLabel: string; baseSha: string;
|
|
slots: TrainSlot[]; plain: TrainPrefix[]; allowRepoCommands: boolean; now: Date;
|
|
}
|
|
|
|
const IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue',
|
|
GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' };
|
|
|
|
/** A path a stamp may write: version files, the CHANGELOG, fragments, release outputs, stamp paths and stamp files. */
|
|
export function isReleasePath(root: string, policy: ShipPolicy, versionRel: string, rel: string): boolean {
|
|
const dir = policy.changelog_fragments?.dir.replace(/\/+$/, '');
|
|
return versionFiles(root, versionRel).includes(rel) || rel === policy.changelog || (!!dir && rel.startsWith(dir + '/'))
|
|
|| matchesAny(policy.release_outputs, rel) || matchesAny(policy.stamp_paths, rel) || matchesAny(policy.stamp_files, rel);
|
|
}
|
|
|
|
/**
|
|
* S_i is merge(S_(i-1), pin_i) modulo stamps, keeps every release entry of S_(i-1) (merged's first parent)
|
|
* byte for byte, and equals T_i outside release paths. Null when all hold, else why not.
|
|
*/
|
|
export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null {
|
|
const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel });
|
|
if ('error' in r) return r.error;
|
|
if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`;
|
|
const cl = o.policy.changelog;
|
|
if (cl) {
|
|
const read = (rev: string) => git(o.repoRoot, ['show', `${rev}:${cl}`]);
|
|
const before = read(`${merged}^1`);
|
|
const after = read(stamped);
|
|
if (before.ok && !after.ok) return `the stamp removed ${cl}`;
|
|
const kept = new Map(parseChangelog(after.out).sections.filter(s => s.version).map(s => [s.version!, s.lines.join('\n').trimEnd()]));
|
|
const lost = parseChangelog(before.ok ? before.out : '').sections.filter(s => s.version && kept.get(s.version) !== s.lines.join('\n').trimEnd());
|
|
if (lost.length) return `the stamp rewrote or dropped the earlier release entr${lost.length > 1 ? 'ies' : 'y'} ${lost.map(s => `[${s.version}]`).join(', ')} in ${cl}`;
|
|
}
|
|
const at = versionAtRef(o.repoRoot, stamped);
|
|
const versionRel = 'rel' in at ? at.rel : 'VERSION';
|
|
const diff = git(o.repoRoot, ['diff', '--name-only', '--no-renames', plainTree, `${stamped}^{tree}`]);
|
|
if (!diff.ok) return `cannot compare with the plain prefix: ${diff.err.split('\n')[0]}`;
|
|
const beyond = diff.out.split('\n').filter(f => f && !isReleasePath(o.repoRoot, o.policy, versionRel, f));
|
|
return beyond.length ? `differs from the plain prefix outside release paths: ${beyond.slice(0, 8).join(', ')}` : null;
|
|
}
|
|
|
|
/** Build S_1..S_k. A conflict, a refused stamp or a failed check stops at that slot with nothing pushed. */
|
|
export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; lines: string[] } | QueueFailure {
|
|
const stamped: StampedPrefix[] = [];
|
|
const lines: string[] = [];
|
|
const date = o.now.toISOString().slice(0, 10);
|
|
for (const [i, slot] of o.slots.entries()) {
|
|
const pred = i === 0 ? o.baseSha : stamped[i - 1]!.commit;
|
|
const m = mergeCommit(o.repoRoot, pred, slot.head, o.policy, `train S${i + 1} (unstamped): merge #${slot.pr} at ${slot.head.slice(0, 12)}`);
|
|
if ('conflicts' in m) return { code: 'QUEUE_TRAIN_CONFLICT', message: `#${slot.pr} conflicts with S${i}: ${m.conflicts.join(', ')}` };
|
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-train-stamp-'));
|
|
fs.rmSync(wt, { recursive: true, force: true });
|
|
if (!git(o.repoRoot, ['worktree', 'add', '--detach', '-q', wt, m.commit]).ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot check out the merge for S${i + 1}` };
|
|
let tree = '';
|
|
let version = '';
|
|
try {
|
|
const at = versionAtRef(o.repoRoot, m.commit);
|
|
const linked = linkedReleaseFile(wt, o.policy, 'rel' in at ? at.rel : 'VERSION');
|
|
if (linked) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): ${linked} is a symlink in the merged tree; the stamp writes release files only inside the repository` };
|
|
const r = restamp({ repoRoot: wt, base: o.base, baseRef: pred, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: new Date(`${date}T12:00:00Z`), env: process.env });
|
|
if (r.status !== 'stamped') return { code: r.code ?? 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix };
|
|
if (!git(wt, ['add', '-A']).ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot stage the stamp for S${i + 1}` };
|
|
tree = git(wt, ['write-tree']).out;
|
|
if (!/^[0-9a-f]{40,64}$/.test(tree)) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot write the stamped tree for S${i + 1}` };
|
|
version = r.newVersion!;
|
|
} finally {
|
|
git(o.repoRoot, ['worktree', 'remove', '--force', wt]);
|
|
fs.rmSync(wt, { recursive: true, force: true });
|
|
}
|
|
const c = git(o.repoRoot, ['commit-tree', '--no-gpg-sign', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)} v${version}`], { ...process.env, ...IDENTITY });
|
|
if (!c.ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `commit-tree failed for S${i + 1}: ${c.err.slice(0, 120)}` };
|
|
const plain = o.plain[i + 1];
|
|
const why = plain ? stampMismatch(o, m.commit, c.out, plain.tree) : `no plain prefix T${i + 1}`;
|
|
if (why) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): ${why}` };
|
|
stamped.push({ index: i + 1, commit: c.out, tree, pr: slot.pr, version, date });
|
|
lines.push(`S${i + 1}: #${slot.pr} v${version} ${c.out.slice(0, 12)} tree=${tree.slice(0, 12)} (merge of S${i} and the pin, modulo stamps; T${i + 1} outside release paths)`);
|
|
}
|
|
return { stamped, lines };
|
|
}
|