mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
v1.91.340.0 ship-queue: train land counts a slot as merged only when GitHub reports it MERGED (#3603) (#3613)
* 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> * fix(train-land): count a slot as merged only when GitHub reports its PR MERGED (#3603) 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>
This commit is contained in:
1 parent
3df7484bc7
commit
7bc2034724
8 files changed
+80
-13
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.340.0] - 2026-10-30
|
||||
|
||||
### Changed
|
||||
- **A stamped train counts a slot as merged only when GitHub reports its PR MERGED** (`lib/ship-queue-train-verify.ts`, #3603): for a stamped train, `train land` refuses to confirm a PR GitHub reports open even when main's tree is its S_i (`QUEUE_PROOF_MISSING`), and the merged count, the freeze decision and the freeze record read every slot's GitHub state instead of trusting the record's `merged` entries. Plain trains are unchanged.
|
||||
|
||||
## [1.91.339.0] - 2026-10-30
|
||||
|
||||
**Merge trains can stamp every slot themselves under `changelog_fragments`: `train build` builds a stamped prefix for each slot and `train land` publishes it, so no lane stamps a PR by hand.** gstack's own policy still leaves the key off (docs/ship-policy.md#changelog-fragments).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.339.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.340.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -414,6 +414,10 @@ Slots merge with `train land --train <nonce> --pr <n>`, never a slot call:
|
||||
4. It refuses if main moved during the checks, merges with
|
||||
`--match-head-commit`, and confirms main's tree is S_i exactly. Any other
|
||||
main is the freeze path above (`QUEUE_TRAIN_TREE_MISMATCH`, backstop).
|
||||
A slot counts as merged only when GitHub reports its PR MERGED. Main at
|
||||
S_i with the PR still open refuses (`QUEUE_PROOF_MISSING`), and the
|
||||
train's merged count, its freeze decision and the freeze record read
|
||||
GitHub's state for every slot, never the record's `merged` list alone.
|
||||
|
||||
`verify --train` refuses a stamped train and names `train land`. The backstop
|
||||
covers main only when its exact tree is some S_i of a green record (plain
|
||||
|
||||
@@ -4273,7 +4273,10 @@ and whether a slot merged on GitHub cannot be read (so whether the queue must
|
||||
freeze is unknown; it never answers "nothing merged" from an unreadable
|
||||
state), `train land` when the base or the train branch cannot be fetched or
|
||||
the record's T0 and S_1..S_k are not the gated head's first-parent chain
|
||||
(each S_i's second parent slot i's pin), and `ready --screen` when the
|
||||
(each S_i's second parent slot i's pin), `train land` when main's tree is the
|
||||
slot's S_i but GitHub does not report the PR as MERGED (a record whose slot
|
||||
numbers or merged entries disagree with GitHub),
|
||||
and `ready --screen` when the
|
||||
scratch merge branch cannot be pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says
|
||||
`committed=yes`.
|
||||
|
||||
|
||||
@@ -113,24 +113,29 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa
|
||||
* Freeze the queue: the record goes to local state and the notes ref before
|
||||
* anything remote, then the backstop gate is dispatched on main's sha.
|
||||
* `states` is the one GitHub read of every unverified slot, made by the
|
||||
* caller: a slot that read MERGED is merged, verified tree or not, and a slot
|
||||
* caller (for a stamped train every slot: its record entries never count on
|
||||
* their own): a slot that read MERGED is merged, verified tree or not, and a slot
|
||||
* that could not be read is left out of `unmerged`, recorded under
|
||||
* `unreadable` and named in the reason and in the NEXT after the repair (the
|
||||
* operator seats it by hand once gh answers), so the rebuild after the repair
|
||||
* never seats a PR that may already be in.
|
||||
*/
|
||||
/** A record entry that counts as merged without a GitHub read: plain trains only; a stamped train counts only what GitHub reports MERGED (#3603). */
|
||||
const recordedMerged = (t: TrainRecord, pr: string): boolean => !t.stamped && t.merged.some(m => m.pr === pr);
|
||||
|
||||
async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSha: string, states: ReadonlyMap<string, string>): Promise<FreezeRecord> {
|
||||
const now = nowIso();
|
||||
const unverified = t.slots.filter(s => !t.merged.some(m => m.pr === s.pr));
|
||||
const unverified = t.slots.filter(s => !recordedMerged(t, s.pr));
|
||||
const landed = unverified.filter(s => states.get(s.pr) === 'MERGED').map(s => s.pr);
|
||||
const untreed = landed.filter(pr => !t.merged.some(m => m.pr === pr));
|
||||
const unreadable = unverified.filter(s => states.get(s.pr) === 'unreadable').map(s => s.pr);
|
||||
const prs = (list: string[]) => list.map(p => '#' + p).join(', ');
|
||||
const f: FreezeRecord = {
|
||||
schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.merged.map(m => m.pr), ...landed],
|
||||
schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.slots.filter(s => recordedMerged(t, s.pr)).map(s => s.pr), ...landed],
|
||||
unmerged: unverified.filter(s => !landed.includes(s.pr) && !unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })),
|
||||
unreadable: unverified.filter(s => unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })),
|
||||
main: { sha: mainSha, tree: git(env.ctx.repoRoot, ['rev-parse', `${mainSha}^{tree}`]).out },
|
||||
reason: [reason, landed.length ? `${prs(landed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '),
|
||||
reason: [reason, untreed.length ? `${prs(untreed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '),
|
||||
backstop: null, repair: null,
|
||||
created_at: now, cleared_at: null, cleared_by: null, updated_at: now,
|
||||
};
|
||||
@@ -173,12 +178,12 @@ function verifiedElsewhere(env: TrainEnv, t: TrainRecord, mainSha: string): stri
|
||||
export async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise<TrainOut> {
|
||||
const run = trainFile(t.nonce);
|
||||
// One GitHub read per unverified slot decides both the freeze and its record.
|
||||
const states = new Map(t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const));
|
||||
const landed = t.merged.length > 0 || [...states.values()].includes('MERGED');
|
||||
const states = new Map(t.slots.filter(s => !recordedMerged(t, s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const));
|
||||
const landed = t.slots.some(s => recordedMerged(t, s.pr)) || [...states.values()].includes('MERGED');
|
||||
const unreadable = landed ? [] : [...states].filter(([, state]) => state === 'unreadable').map(([pr]) => `#${pr}`);
|
||||
if (unreadable.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${message}; whether ${unreadable.join(', ')} merged on GitHub could not be read, so whether the queue must freeze is unknown` }, lines, `${Q} verify --pr ${t.slots[0]!.pr} --train ${t.nonce} (once gh pr view answers)`, run);
|
||||
if (!landed) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run);
|
||||
const later = fetched && t.slots.every(s => t.merged.some(m => m.pr === s.pr) || states.get(s.pr) === 'MERGED') ? verifiedElsewhere(env, t, mainSha) : null;
|
||||
const later = fetched && t.slots.every(s => recordedMerged(t, s.pr) || states.get(s.pr) === 'MERGED') ? verifiedElsewhere(env, t, mainSha) : null;
|
||||
if (later) {
|
||||
const held = activeFreeze(env.repo.keys);
|
||||
return outRefused({ code: 'QUEUE_TRAIN_STALE', message: `${message}; every slot of train ${t.nonce} merged on GitHub and main ${mainSha.slice(0, 12)} is ${later}, so this record is stale and ${held ? `freezes nothing (the queue is already frozen by train ${held.train})` : 'the queue is not frozen'}` }, held ? [...lines, ...frozenLines(held)] : lines, `${Q} train prune --stale --train ${t.nonce}`, run);
|
||||
@@ -233,11 +238,14 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
|
||||
export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut {
|
||||
const run = trainFile(t.nonce);
|
||||
const state = t.stamped ? prState(env.ctx, pr) : 'MERGED';
|
||||
if (state !== 'MERGED') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `main's tree is S${index} of train ${t.nonce}, but GitHub reports #${pr} as ${state}, not MERGED; only a merge GitHub reports counts` }, lines, `gh pr view ${pr} --json state,headRefOid; rerun once GitHub reports #${pr} MERGED`, run);
|
||||
if (!t.merged.some(m => m.pr === pr)) t.merged.push({ pr, sha: main, tree: git(env.ctx.repoRoot, ['rev-parse', `${main}^{tree}`]).out, at: nowIso() });
|
||||
t.state = t.merged.length === t.slots.length ? 'merged' : 'merging';
|
||||
const merged = t.slots.filter(s => s.pr === pr || recordedMerged(t, s.pr) || (t.merged.some(m => m.pr === s.pr) && prState(env.ctx, s.pr) === 'MERGED'));
|
||||
t.state = merged.length === t.slots.length ? 'merged' : 'merging';
|
||||
effect(t, `verified #${pr} merged as T${index}`);
|
||||
save(env, t);
|
||||
lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`);
|
||||
lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${merged.length}/${t.slots.length} merged)`);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') {
|
||||
freeze.cleared_at = nowIso();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.339",
|
||||
"version": "1.91.340",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -251,3 +251,50 @@ describe('stamped trains: build and land', () => {
|
||||
expect(r.err).toMatch(/was built without stamped prefixes.*\(QUEUE_GATE_MISSING\)/);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('train land counts only merges GitHub reports (#3603)', () => {
|
||||
const trainPath = (fx: TrainFixture, nonce: string) => path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json');
|
||||
const landFirst = (fx: TrainFixture, nonce: string) => { land(fx, nonce, '1'); expect(land(fx, nonce, '1').status).toBe(EXIT.ok); };
|
||||
|
||||
test('a record with its slot PRs swapped never confirms the open PR as merged', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
landFirst(fx, nonce);
|
||||
const t = trainJson(fx, nonce);
|
||||
fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, slots: [{ ...t.slots[0], pr: '2' }, { ...t.slots[1], pr: '1' }] }));
|
||||
const r = land(fx, nonce, '2');
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(r.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m);
|
||||
expect(fx.read().prs['2']).not.toMatchObject({ state: 'MERGED' });
|
||||
expect(trainJson(fx, nonce).merged.map((m: any) => m.pr)).toEqual(['1']);
|
||||
}, 300_000);
|
||||
|
||||
test('a forged merged entry for an open slot does not mark the train merged', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, merged: [{ pr: '2', sha: t.prefixes[0].commit, tree: t.prefixes[0].tree, at: '2026-10-29T00:00:00Z' }] }));
|
||||
landFirst(fx, nonce);
|
||||
const rerun = land(fx, nonce, '1');
|
||||
expect(rerun.out).toContain('(1/2 merged)');
|
||||
expect(trainJson(fx, nonce).state).toBe('merging');
|
||||
}, 300_000);
|
||||
|
||||
test('a forged merged entry does not turn a pre-landing mismatch into a freeze', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, merged: [{ pr: '1', sha: t.prefixes[0].commit, tree: t.prefixes[0].tree, at: '2026-10-29T00:00:00Z' }] }));
|
||||
fx.repo.git('fetch', '-q', 'origin');
|
||||
fx.repo.git('checkout', '-q', '--detach', 'origin/main');
|
||||
fs.writeFileSync(path.join(fx.repo.work, 'outside.txt'), 'outside\n');
|
||||
fx.repo.git('add', 'outside.txt');
|
||||
fx.repo.git('commit', '-q', '-m', 'outside');
|
||||
fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
const r = land(fx, nonce, '1');
|
||||
expect(r.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
||||
expect(r.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user