mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Move the PR body's machine-read lines into a closing Agent notes section The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Teach /ship to write plain titles, PR openings, commit messages and changelog entries The README now lists what gstack is for, so a PR opening can name the goal it serves. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep tests, size budgets and generated files in step with plain titles and Agent notes Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the test-profile list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep text people add below the PR notes, and fix three smaller review findings The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes. Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Mask credentials in refused-title lines and keep older PRs' plan lines readable A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Print a fixed placeholder when a credential in a refused title cannot be masked Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a plain-title policy edit from scheduling every paid eval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the paid-eval list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Recheck the PR title on every edit so a base change cannot turn a refused title green Review-Finding: s11/targeted#1 reader=targeted Review-Finding: s11/adversarial-native#1 reader=adversarial-native 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>
287 lines
18 KiB
TypeScript
287 lines
18 KiB
TypeScript
/**
|
|
* Stamped trains (changelog_fragments; GSTA-154 PR B): train build makes S_1..S_k beside T_1..T_k and gates
|
|
* S_k; train land publishes each slot's S_i exactly (a tool-made stamp commit on [pin, main], pushed under
|
|
* the pin's lease, labelled evals-no-debounce), confirms main is S_i, and freezes on any other main.
|
|
* Fixture repos with a bare origin and the train gh shim, wrapped for pr edit, pr merge and the PR's checks.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { EXIT } from '../lib/headless-artifacts';
|
|
import { cleanup } from './helpers/restamp-fixture';
|
|
import {
|
|
GREEN_GATE, RED_GATE, TRAIN_POLICY, fragmentPr, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, wrapLand as wrap, type TrainFixture,
|
|
} from './helpers/ship-queue-train-fixture';
|
|
|
|
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
|
const fixtures: TrainFixture[] = [];
|
|
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
|
|
|
function stampedTrain(gate = GREEN_GATE, policy: Record<string, unknown> = POLICY) {
|
|
const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
|
|
const h1 = fragmentPr(fx, '1');
|
|
const h2 = fragmentPr(fx, '2', '---\nbump: minor\n---\n- #2 change\n');
|
|
fx.write(d => { d.rules['evals.yml'] = gate; });
|
|
const built = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]);
|
|
const nonce = nonceOf(built.out);
|
|
return { fx, h1, h2, built, nonce };
|
|
}
|
|
const land = (fx: TrainFixture, nonce: string, pr: string) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait']);
|
|
const originMain = (fx: TrainFixture) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main');
|
|
|
|
describe('stamped trains: build and land', () => {
|
|
test('build records S1, S2 and gates S2; land publishes each slot exactly, with the label, and records the merge', () => {
|
|
const { fx, h1, h2, built, nonce } = stampedTrain();
|
|
expect(built.status, built.err).toBe(EXIT.ok);
|
|
expect(built.out).toMatch(/^S1: #1 v1\.2\.4\.0 [0-9a-f]{12} /m);
|
|
expect(built.out).toMatch(/^S2: #2 v1\.3\.0\.0 [0-9a-f]{12} /m);
|
|
const t = trainJson(fx, nonce);
|
|
expect(t.stamped.map((s: any) => [s.pr, s.version])).toEqual([['1', '1.2.4.0'], ['2', '1.3.0.0']]);
|
|
expect(t.head).toBe(t.stamped[1].commit);
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
|
|
const verify = queue(fx, ['verify', '--pr', '1', '--train', nonce]);
|
|
expect(verify.err).toMatch(/has stamped prefixes.*\(QUEUE_GATE_MISSING\)/);
|
|
expect(line(verify.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 1`);
|
|
const early = land(fx, nonce, '2');
|
|
expect(early.err).toMatch(/main is the train base; land #1 \(slot 1\) first.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
|
const pushed = land(fx, nonce, '1');
|
|
expect(pushed.out, pushed.err).toContain('LABEL: evals-no-debounce added');
|
|
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!;
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(stamp);
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-list', '--parents', '-n', '1', stamp).split(' ').slice(1)).toEqual([h1, originMain(fx)]);
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}')).toBe(t.stamped[0].tree);
|
|
const merged = land(fx, nonce, '1');
|
|
expect(merged.status, merged.err).toBe(EXIT.ok);
|
|
expect(merged.out).toContain("main's tree is S1 exactly");
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(t.stamped[0].tree);
|
|
land(fx, nonce, '2');
|
|
const second = land(fx, nonce, '2');
|
|
expect(second.status, second.err).toBe(EXIT.ok);
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'show', 'main:VERSION')).toBe('1.3.0.0');
|
|
expect(trainJson(fx, nonce)).toMatchObject({ state: 'merged' });
|
|
expect(fx.read().prs['2']).toMatchObject({ state: 'MERGED' });
|
|
expect((fx.read() as any).labels).toEqual({ 1: 'evals-no-debounce', 2: 'evals-no-debounce' });
|
|
expect((fx.read() as any).mergeArgs['1']).toEqual(['--squash', '--match-head-commit', stamp, '--subject', 'Change from #1 (#1)']);
|
|
void h2;
|
|
}, 300_000);
|
|
|
|
// Value: protects=under GSTACK_PLAN_RECORD=require train land refuses a slot whose READY receipt has no plan record before pushing a stamp; fails_when=slotPlan's wiring in landUpdate is dropped or runs after the push; why_new=ship-queue-train tests cover train ready only, not train land; seam=none; no_claim=landDirect/stamp-commit slotPlan paths and a matching-plan land (train-built receipts carry no plan)
|
|
test('under GSTACK_PLAN_RECORD=require train land refuses a READY receipt with no plan record and pushes nothing', () => {
|
|
const { fx, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
const before = fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1');
|
|
const refused = queue(fx, ['train', 'land', '--train', nonce, '--pr', '1', '--no-wait'], { GSTACK_PLAN_RECORD: 'require' });
|
|
expect(refused.status).toBe(EXIT.refused);
|
|
expect(refused.err).toContain('(QUEUE_PLAN_MISMATCH)');
|
|
expect(refused.out).toContain('PLAN: refused the READY note carries no plan record');
|
|
expect(refused.out).not.toMatch(/^PUSHED: /m);
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(before);
|
|
expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' });
|
|
}, 300_000);
|
|
|
|
test('a foreign push to the PR branch is never overwritten; a merge outside the train freezes the queue', () => {
|
|
const { fx, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
fx.repo.git('push', '-q', '-f', 'origin', 'origin/main:refs/heads/pr-1');
|
|
const moved = land(fx, nonce, '1');
|
|
expect(moved.err).toContain('(QUEUE_TRAIN_MOVED)');
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main'));
|
|
|
|
const second = stampedTrain();
|
|
expect(queue(second.fx, ['train', 'verdict', '--train', second.nonce]).status).toBe(EXIT.ok);
|
|
land(second.fx, second.nonce, '1');
|
|
expect(land(second.fx, second.nonce, '1').status).toBe(EXIT.ok);
|
|
land(second.fx, second.nonce, '2');
|
|
second.fx.write(d => { (d as any).outsideOnMerge = true; });
|
|
const drift = land(second.fx, second.nonce, '2');
|
|
expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)');
|
|
expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/);
|
|
}, 300_000);
|
|
|
|
test('a record that is not the gated head\'s first-parent chain (any slot\'s commit, tree or pin) pushes nothing, merges nothing and leaves the PR branch in place', () => {
|
|
const { fx, h1, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
const file = path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json');
|
|
const t = trainJson(fx, nonce);
|
|
const base = t.prefixes[0];
|
|
const [s1, s2] = t.stamped;
|
|
const forged = [
|
|
{ ...t, stamped: [{ ...s1, commit: h1, tree: fx.repo.git('rev-parse', h1 + '^{tree}') }, s2] },
|
|
{ ...t, stamped: [{ ...s1, tree: 'f'.repeat(40) }, s2] },
|
|
{ ...t, stamped: [{ ...s2 }, s2] },
|
|
{ ...t, stamped: [{ ...s1, index: 0, commit: base.commit, tree: base.tree }, s2] },
|
|
{ ...t, stamped: [s1, { ...s2, tree: base.tree }] },
|
|
{ ...t, stamped: [s1] },
|
|
{ ...t, slots: [{ ...t.slots[0], head: t.slots[1].head }, t.slots[1]] },
|
|
];
|
|
for (const record of forged) {
|
|
fs.writeFileSync(file, JSON.stringify(record));
|
|
const r = land(fx, nonce, '1');
|
|
expect(r.status).toBe(EXIT.refused);
|
|
expect(r.err).toMatch(/are not the first-parent chain of the gated head.*\(QUEUE_PROOF_MISSING\)/);
|
|
expect(r.out).not.toMatch(/^PUSHED:/m);
|
|
}
|
|
expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' });
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1);
|
|
}, 180_000);
|
|
|
|
test('a rerun after the stamp push but before the journal recognizes its own stamp and merges', () => {
|
|
const { fx, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
const pushed = land(fx, nonce, '1');
|
|
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!;
|
|
fs.rmSync(path.join(fx.state, 'ship-queue', 'trains', nonce, 'land-1.json'));
|
|
const resumed = land(fx, nonce, '1');
|
|
expect(resumed.status, resumed.err).toBe(EXIT.ok);
|
|
expect(resumed.out).toContain("main's tree is S1 exactly");
|
|
expect(fx.read().prs['1']).toMatchObject({ state: 'MERGED' });
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}'));
|
|
}, 300_000);
|
|
|
|
test('a rerun after the journal but before the stamp push pushes the stamp instead of stopping', () => {
|
|
const { fx, h1, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(land(fx, nonce, '1').out)![1]!;
|
|
fx.repo.git('push', '-q', '-f', 'origin', `${h1}:refs/heads/pr-1`);
|
|
const again = land(fx, nonce, '1');
|
|
expect(again.err).not.toContain('QUEUE_TRAIN_MOVED');
|
|
expect(again.out).toContain(`PUSHED: ${stamp}`);
|
|
expect(land(fx, nonce, '1').status).toBe(EXIT.ok);
|
|
}, 300_000);
|
|
|
|
test('a slot landed out of order after an earlier slot merged refuses without freezing and names the next slot; a rerun of a landed slot confirms', () => {
|
|
const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
|
|
const heads = ['1', '2', '3'].map(pr => fragmentPr(fx, pr));
|
|
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
|
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out);
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
land(fx, nonce, '1');
|
|
expect(land(fx, nonce, '1').status).toBe(EXIT.ok);
|
|
const early = land(fx, nonce, '3');
|
|
expect(early.err).toMatch(/main is S1; land #2 \(slot 2\) first.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
|
expect(early.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
|
|
expect(line(early.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 2`);
|
|
land(fx, nonce, '2');
|
|
expect(land(fx, nonce, '2').status).toBe(EXIT.ok);
|
|
const rerun = land(fx, nonce, '1');
|
|
expect(rerun.status, rerun.err).toBe(EXIT.ok);
|
|
expect(rerun.out).toContain('is S2, past S1');
|
|
expect(rerun.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
|
|
}, 300_000);
|
|
|
|
test('a failed stamp push with the branch still at the pin fails retryably instead of reporting a move', () => {
|
|
const { fx, h1, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
|
|
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
|
const refused = land(fx, nonce, '1');
|
|
expect(refused.err).toMatch(/the land lock push to origin failed and the lock there is unchanged; nothing was pushed.*\(QUEUE_NOT_READY\)/);
|
|
expect(refused.err).not.toContain('another lander');
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1);
|
|
fs.writeFileSync(hook, '#!/bin/sh\nwhile read old new ref; do [ "$ref" = refs/heads/pr-1 ] && exit 1; done\nexit 0\n', { mode: 0o755 });
|
|
const failed = land(fx, nonce, '1');
|
|
expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/);
|
|
expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED');
|
|
fs.rmSync(hook);
|
|
expect(land(fx, nonce, '1').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
|
}, 300_000);
|
|
|
|
test('a required check that never reported on the stamp commit holds the merge and names the check', () => {
|
|
const { fx, nonce } = stampedTrain(GREEN_GATE, { ...POLICY, changelog_fragments: { dir: 'changelog.d', required_checks: ['free-tests', 'windows-free-shard'] } });
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(land(fx, nonce, '1').out)![1]!;
|
|
const held = land(fx, nonce, '1');
|
|
expect(held.err).toMatch(/required checks missing or not green on the stamp commit \S+: windows-free-shard; fix: wait for windows-free-shard to pass on \S+ \(dispatch the workflow on pr-1 if it never ran\).*\(QUEUE_NOT_READY\)/);
|
|
expect(held.out).toContain(`REQUIRED_CHECKS: not green at ${stamp.slice(0, 12)}: windows-free-shard`);
|
|
expect(line(held.out, 'NEXT:')).toMatch(/^NEXT: wait for windows-free-shard to pass on /);
|
|
expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' });
|
|
}, 300_000);
|
|
|
|
test('a PR retargeted off the train base refuses before any push', () => {
|
|
const { fx, h1, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
fx.write(d => { (d.prs['1'] as any).baseRefName = 'release'; });
|
|
const r = land(fx, nonce, '1');
|
|
expect(r.err).toMatch(/targets release, not main.*\(QUEUE_NOT_READY\)/);
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1);
|
|
}, 180_000);
|
|
|
|
test('a red stamped train bisects its stamped chain: T0 and S1..Sk, each an ancestor of the train head', () => {
|
|
const { fx, nonce } = stampedTrain(RED_GATE);
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.fail);
|
|
const dry = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5', '--dry-run']);
|
|
expect(dry.err).not.toContain('QUEUE_TRAIN_PREFIX_INVALID');
|
|
expect(dry.out).toContain('BISECT: cases c1 prefixes T0,T1,T2 need results');
|
|
}, 180_000);
|
|
|
|
test('the receipt check is the depth-checked one train build uses: a receipt whose writer records depth but which records none refuses, nothing pushed', () => {
|
|
const { fx, h1, nonce } = stampedTrain();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
fx.write(d => { (d.prs['1'] as any).body = shippedBody('1', h1, { gstack_version: '1.91.307.0' }); });
|
|
const r = land(fx, nonce, '1');
|
|
expect(r.err).toContain('(QUEUE_RECEIPT_DEPTH_RAISED)');
|
|
expect(r.out).toMatch(/^REVIEW_DEPTH: receipt=none required=/m);
|
|
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1);
|
|
}, 180_000);
|
|
|
|
test('a plain train refuses train land and keeps the slot-stamp path', () => {
|
|
const fx = trainFixture(TRAIN_POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
|
|
const h1 = openPr(fx, '1');
|
|
readyReceipt(fx, '1', h1);
|
|
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
|
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out);
|
|
expect(trainJson(fx, nonce).stamped).toBeUndefined();
|
|
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
|
const r = land(fx, nonce, '1');
|
|
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);
|
|
});
|
|
|