mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
The lander must accept each owner-approved gate change before a PR merges (GST-2491) (#4304)
* Let an owner recorded approval close a gate finding in unattended ship (GST-2491) An unattended /ship logs every gate finding open and nothing could close it, so a PR with an owner-approved gate change could never finish. gstack-approvals gate records the owner disposition outside the run; it closes only that fingerprint on a review row whose range holds the head, and finish, the receipt, the PR body and READY all carry it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the pinned review brief unchanged; document the resume steps for a dispositioned gate finding (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Bind a gate disposition to its gate change and show it as kept to the rest of /ship (GST-2491) A disposition now names the finding's gate_id as well as its fingerprint, so a later loosening of the same line stays open. gstack-review-read shows a closed gate finding as kept with the disposition's approval id, so the existing --blocking-open, --gate-open, draft and Gate edits rules carry it without prose changes, and an unreadable approval record applies no disposition instead of failing the reader. Review-Finding: p1/red-team#1 reader=red-team Review-Finding: s11/outside-voice#1 reader=outside-voice Review-Finding: s11/outside-voice#2 reader=outside-voice Review-Finding: s11/adversarial-native#1 reader=adversarial-native Review-Finding: s11/adversarial-native#2 reader=adversarial-native Review-Finding: s11/adversarial-native#4 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * List the Gate disposition line in the receipt and prose troubleshooting entries (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Require the lander to accept each owner gate disposition before merging (GST-2491) Land and a new read-only verify --pin re-derive the dispositions the approval store applies at the pin and merge only with --accept-gate-disposition for each one. The gate writer refuses while any worktree holds an open /ship run on the branch, under a lock; a row counts only in the writer's full shape with its digest; receipt write needs --body when a disposition applies; ready and land cross-check the receipt and body against the store. Supersedes #4299. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Regenerate the result-code anchors for the gate-disposition refusal (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Always record the receipt's gate dispositions and refuse a store that disagrees at merge (GST-2491) The receipt always carries gate_dispositions (an empty list when none apply) and write refuses on an unreadable store or a git error. Land, verify and ready refuse a receipt id the merge-time store lacks, naming the likely wrong state root or mirror, and a field-less receipt beside an applied row. A git ancestry error while deriving dispositions refuses instead of reading as not applied. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Count a disposition whose head a rewrite removed as not applied, and expect the receipt to end with gate_dispositions (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Trim and check the gate disposition recorder name, and let a parked run's ended lease stop blocking it (GST-2491) Review-Finding: s11/outside-voice#3 reader=outside-voice Review-Finding: s11/outside-voice#2 reader=outside-voice 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
842fef1f95
commit
b1f78301db
28 files changed
+1050
-38
No files matched your search
@@ -145,7 +145,7 @@ describe('E11: the documentation audit record and the doc caps', () => {
|
||||
const receipt = extractReceipt(fs.readFileSync(body, 'utf8'));
|
||||
expect(receipt.errors).toEqual([]);
|
||||
expect((receipt.receipt as any).docs).toEqual({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } });
|
||||
expect(Object.keys(receipt.receipt!).slice(-2)).toEqual(['docs', 'review_loop']);
|
||||
expect(Object.keys(receipt.receipt!).slice(-3)).toEqual(['docs', 'review_loop', 'gate_dispositions']);
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--body', body]).status).toBe(EXIT.ok);
|
||||
const malformed = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"done","scope":"docs/"}']);
|
||||
expect(malformed.status).toBe(EXIT.fail);
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('gstack-ship-receipt write', () => {
|
||||
const parsed = extractReceipt(ok.out);
|
||||
expect(parsed.found).toBe(true);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth']);
|
||||
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'gate_dispositions']);
|
||||
expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } });
|
||||
expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}'));
|
||||
expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/);
|
||||
@@ -714,7 +714,7 @@ describe('review_loop receipt input (harness lane E: the factory scorecard count
|
||||
expect(extractReceipt(withLoop.out).receipt).toMatchObject({ pr: 42, review_loop: { passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' } });
|
||||
const written = extractReceipt(fs.readFileSync(loopBody, 'utf8'));
|
||||
expect(written.errors).toEqual([]);
|
||||
expect(Object.keys(written.receipt!).slice(-1)).toEqual(['review_loop']);
|
||||
expect(Object.keys(written.receipt!).slice(-2)).toEqual(['review_loop', 'gate_dispositions']);
|
||||
});
|
||||
|
||||
test('parseReviewLoop keeps integer counters and drops the rest; write --review-loop refuses a malformed record and writes nothing', () => {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Owner gate dispositions (GST-2491, founderstar#906 at 3a273935b): an unattended /ship logs every gate finding `open`
|
||||
* (gstack-review-log), and only the owner's `gstack-approvals gate` row, at that fingerprint and a head inside the review
|
||||
* row's range, closes it. Rulings, the lane's own review-log claims and rows recorded inside the open /ship run close nothing,
|
||||
* and every disposition is printed by finish and carried by the receipt, the PR body and READY.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt, readyTailLines } from '../lib/ship-receipt';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { buildRow, locate, type ApprovalRow, type StoreLocation } from '../lib/approval-record';
|
||||
import { GATE_SOURCE, gateDispositionDigest, gateDispositionRow } from '../lib/gate-dispositions';
|
||||
|
||||
setDefaultTimeout(90_000);
|
||||
const BIN = path.dirname(RELEASE);
|
||||
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
|
||||
const ships: ShipFx[] = [];
|
||||
afterEach(() => { while (ships.length) ships.pop()!.cleanup(); });
|
||||
|
||||
const GATE = 'tests/balance.test.ts:3:gate-integrity';
|
||||
const OTHER = 'tests/balance.test.ts:9:gate-integrity';
|
||||
const GATE_ID = '69c7c3432501';
|
||||
const WORDS = 'Do it';
|
||||
const REASON = 'fed-careless gate allows 6 of 48 seeds';
|
||||
const BY = 'Founderstar Captain (FOUN-1)';
|
||||
const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(4).toBeLessThanOrEqual(4); });\n";
|
||||
const TEST_V2 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(4).toBeLessThanOrEqual(6); });\n";
|
||||
/** The founderstar#906 review row: one RH-15 gate finding an unattended session logs open, and a below-floor note. */
|
||||
const gateRow = (extra: Record<string, unknown> = {}) => ({
|
||||
completed: true, converged: true, status: 'issues_found', issues_found: 1, critical: 1, informational: 0,
|
||||
findings: [
|
||||
{ fingerprint: GATE, severity: 'CRITICAL', locus: 'diff', kind: 'defect', blocking: true, raised_by: 'core', gate: 'RH-15', gate_id: GATE_ID, action: 'kept', reason: 'owner approved', ...extra },
|
||||
{ fingerprint: 'src/x.ts:1:naming', severity: 'INFORMATIONAL', action: 'noted', blocking: false, raised_by: 'red-team' },
|
||||
],
|
||||
});
|
||||
const NOT_CLEAN = 'FIX_CHECK: not counted (review has unresolved findings or did not finish clean)';
|
||||
|
||||
const run = (f: ShipFx, bin: string, args: string[]) => f.run(path.join(BIN, bin), args, { GSTACK_APPROVALS_MIRROR: '0' });
|
||||
const gate = (f: ShipFx, finding: string, head: string, o: { words?: string; by?: string; reason?: string; gateId?: string } = {}) =>
|
||||
run(f, 'gstack-approvals', ['gate', '--finding', finding, '--gate-id', o.gateId ?? GATE_ID, '--head', head, '--reason', o.reason ?? REASON, '--words', o.words ?? WORDS, '--by', o.by ?? BY]);
|
||||
const log = (f: ShipFx, skill: string, row: Record<string, unknown>) => {
|
||||
const env = { GSTACK_APPROVALS_MIRROR: '0', GSTACK_SESSION_KIND: 'unattended' };
|
||||
const start = f.run(path.join(BIN, 'gstack-review-log'), ['--start', skill], env);
|
||||
expect(start.status, start.stderr).toBe(0);
|
||||
const logged = f.run(path.join(BIN, 'gstack-review-log'), [JSON.stringify({ skill, timestamp: '2026-11-11T20:30:13Z', via: 'ship', review_scope: 'frozen', ...row }), '--finish', start.stdout.trim()], env);
|
||||
expect(logged.status, logged.stderr).toBe(0);
|
||||
};
|
||||
/** A /ship run open on `dir` as preflight leaves it: the run marker plus an open lease held by this live test process. */
|
||||
const openRun = (f: ShipFx, dir = f.work) => {
|
||||
const marker = path.resolve(dir, spawnSync('git', ['rev-parse', '--git-path', 'gstack-ship-run'], { cwd: dir, encoding: 'utf8', timeout: 60_000 }).stdout.trim());
|
||||
fs.writeFileSync(marker, 'ship-906-3a27393\n');
|
||||
const lease = spawnSync(path.join(BIN, 'gstack-lease'), ['touch', '--root', dir, '--pid', String(process.pid), '--run', 'ship-906-3a27393'], { encoding: 'utf8', timeout: 60_000 });
|
||||
expect(lease.status, lease.stderr).toBe(0);
|
||||
return marker;
|
||||
};
|
||||
/** The newest row of a skill as gstack-review-read prints it. */
|
||||
const read = (f: ShipFx, skill = 'review') => run(f, 'gstack-review-read', []).stdout.split('\n---CONFIG---')[0]!.split('\n')
|
||||
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === skill ? [v] : []; } catch { return []; } }).at(-1);
|
||||
|
||||
/** Pass 1 read the gate change at its commit and logged the gate finding open (unattended). */
|
||||
function firstPass(extra: Record<string, unknown> = {}, rowExtra: Record<string, unknown> = {}) {
|
||||
const f = shipFixture({ 'tests/balance.test.ts': TEST_V1 }, { 'tests/balance.test.ts': TEST_V2 });
|
||||
ships.push(f);
|
||||
log(f, 'review', { pass: 1, range_base: null, ...gateRow(extra), ...rowExtra });
|
||||
return { f, head: f.git('rev-parse', 'HEAD').stdout.trim(), mergeBase: f.git('merge-base', 'main', 'HEAD').stdout.trim() };
|
||||
}
|
||||
|
||||
// Value: protects=founderstar#906: an owner-approved gate change finishes unattended once the owner's disposition is recorded,
|
||||
// and only then; fails_when=the row still reads UNVERIFIED with a matching gate_disposition row, or reads CURRENT without one;
|
||||
// seam=gstack-approvals gate, gstack-review-read and lib/gate-dispositions.ts on the stored review row; no_claim=the review
|
||||
// plan's flags, which /ship derives from this row
|
||||
describe('a recorded owner disposition closes an unattended gate finding (GST-2491)', () => {
|
||||
test('the logger keeps it open, and a matching gate row closes it on the review row', () => {
|
||||
const { f, head } = firstPass();
|
||||
const before = read(f);
|
||||
expect(before.findings[0]).toMatchObject({ action: 'open', actor: 'unattended' });
|
||||
expect([before.review_freshness.status, before.gate_dispositions]).toEqual(['UNVERIFIED', undefined]);
|
||||
const rec = gate(f, GATE, head);
|
||||
expect([rec.status, rec.stdout]).toEqual([0, expect.stringMatching(/^APPROVAL_RECORDED: a-[0-9a-f]{12} /)]);
|
||||
const approval = rec.stdout.split(' ')[1]!;
|
||||
const after = read(f);
|
||||
expect([after.review_freshness.status, after.gate_dispositions]).toEqual(['CURRENT', [approval]]);
|
||||
expect(after.findings[0]).toMatchObject({ action: 'kept', actor: 'unattended', disposition: approval, reason: `owner disposition ${approval}: ${REASON}` });
|
||||
expect(run(f, 'gstack-approvals', ['dispositions']).stdout).toMatch(new RegExp(`^Gate disposition: ${GATE} \\[gate ${GATE_ID}\\] at ${head.slice(0, 12)} by Founderstar Captain \\(FOUN-1\\) at \\d{4}-\\d\\d-\\d\\dT[^ ]+Z: ${REASON} — "Do it" \\(approval a-[0-9a-f]{12}\\)\\n$`));
|
||||
});
|
||||
|
||||
test('negative controls stay open: no row, another fingerprint or gate_id, a head outside the range, a ruling, a gstack row', () => {
|
||||
const { f, head, mergeBase } = firstPass();
|
||||
const open = () => [read(f).review_freshness.status, read(f).gate_dispositions, read(f).findings[0].action];
|
||||
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
|
||||
expect(gate(f, OTHER, head).status).toBe(0);
|
||||
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
|
||||
expect(gate(f, GATE, head, { gateId: '0123456789ab' }).status).toBe(0);
|
||||
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
|
||||
expect(gate(f, GATE, mergeBase).status).toBe(0);
|
||||
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
|
||||
const ruling = run(f, 'gstack-approvals', ['rule', '--finding', GATE, '--head', head, '--reason', REASON, '--words', WORDS, '--by', BY]);
|
||||
expect(ruling.status).toBe(0);
|
||||
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
|
||||
const self = gate(f, GATE, head, { by: 'gstack no-stop default (K1)' });
|
||||
expect([self.status, self.stderr]).toEqual([EXIT.usage, expect.stringContaining('--by names gstack')]);
|
||||
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
|
||||
});
|
||||
|
||||
test('a later head is outside a first pass too: the owner answers at the commit the review read', () => {
|
||||
const { f } = firstPass();
|
||||
f.write({ 'README.md': '# Fx later\n' }); f.commit('later');
|
||||
expect(gate(f, GATE, f.git('rev-parse', 'HEAD').stdout.trim()).status).toBe(0);
|
||||
expect(read(f).review_freshness.status).not.toBe('CURRENT');
|
||||
expect(read(f).gate_dispositions).toBeUndefined();
|
||||
});
|
||||
|
||||
test('gate refuses a call inside the open /ship run, empty or multi-line words, and add with the scope', () => {
|
||||
const { f, head } = firstPass();
|
||||
const marker = openRun(f);
|
||||
const inRun = gate(f, GATE, head);
|
||||
expect([inRun.status, inRun.stderr]).toEqual([EXIT.usage, expect.stringContaining('/ship run ship-906-3a27393 is open on this checkout')]);
|
||||
fs.rmSync(marker);
|
||||
for (const words of ['', ' ']) {
|
||||
const empty = gate(f, GATE, head, { words });
|
||||
expect([empty.status, empty.stderr]).toEqual([EXIT.usage, expect.stringContaining('--words')]);
|
||||
}
|
||||
const noId = gate(f, GATE, head, { gateId: '' });
|
||||
expect([noId.status, noId.stderr]).toEqual([EXIT.usage, expect.stringContaining('--gate-id')]);
|
||||
const lines = gate(f, GATE, head, { words: 'Do it\nand more' });
|
||||
expect([lines.status, lines.stderr]).toEqual([EXIT.usage, expect.stringContaining('one line')]);
|
||||
const add = run(f, 'gstack-approvals', ['add', '--ask', `gate_disposition ${GATE} ${GATE_ID} at ${head}: ${REASON}`, '--words', WORDS, '--by', BY, '--scope', `gate_disposition ${GATE} ${GATE_ID} ${head}`]);
|
||||
expect([add.status, add.stderr]).toEqual([EXIT.usage, expect.stringContaining('gstack-approvals gate')]);
|
||||
expect(run(f, 'gstack-approvals', ['dispositions']).stdout).toBe('GATE_DISPOSITIONS: none\n');
|
||||
expect(read(f).review_freshness.status).toBe('UNVERIFIED');
|
||||
});
|
||||
|
||||
test('the lane\'s own review-log claim closes nothing', () => {
|
||||
const { f, head } = firstPass({ disposition: 'a-6dcd9c1d8fad', approval: 'a-6dcd9c1d8fad', disposition_by: BY }, { gate_dispositions: ['a-6dcd9c1d8fad'] });
|
||||
const row = read(f);
|
||||
expect([row.findings[0].action, row.findings[0].disposition, row.review_freshness.status, row.gate_dispositions]).toEqual(['open', undefined, 'UNVERIFIED', undefined]);
|
||||
expect(run(f, 'gstack-approvals', ['add', '--ask', `Owner approved the gate change at ${head}`, '--words', WORDS, '--by', BY, '--scope', 'this gate edit']).status).toBe(0);
|
||||
expect(read(f).review_freshness.status).toBe('UNVERIFIED');
|
||||
});
|
||||
});
|
||||
|
||||
/** Round 2 stopped at passHead, the owner granted one scoped fix-check, and the fix-check logged the gate finding open. */
|
||||
function grantedFixCheck() {
|
||||
const f = shipFixture({ 'tests/balance.test.ts': TEST_V1 }, { 'src/x.ts': 'export const x = 2;\n', 'tests/balance.test.ts': TEST_V1 });
|
||||
ships.push(f);
|
||||
const passHead = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
|
||||
f.write({ 'src/x.ts': 'export const x = 3;\n', 'tests/balance.test.ts': TEST_V2 }); f.commit('raise the fed-careless gate');
|
||||
const post = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
|
||||
expect(run(f, 'gstack-approvals', ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${passHead.slice(0, 12)})`, '--words', 'one scoped fix-check', '--by', BY, '--scope', 'this /ship invocation']).status).toBe(0);
|
||||
log(f, 'review-fix-check', { range_base: passHead, ...gateRow() });
|
||||
const fin = () => f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
|
||||
return { f, passHead, fixHead: f.git('rev-parse', 'HEAD').stdout.trim(), fin };
|
||||
}
|
||||
const dispositionLines = (out: string) => out.split('\n').filter(l => l.startsWith('GATE_DISPOSITION: '));
|
||||
|
||||
// Value: protects=finish counts a fix-check whose only blocker is an owner-dispositioned gate finding and prints the disposition
|
||||
// word for word; a ruling or an out-of-range disposition leaves it uncounted and says not applied; fails_when=finish ignores
|
||||
// gate dispositions, honours a ruling for a gate finding, or hides a disposition; seam=finish's fixChecked route
|
||||
describe('finish with a gate disposition (GST-2491)', () => {
|
||||
test('a disposition at the stop head closes the fix-check\'s gate finding, and finish prints it', () => {
|
||||
const { f, passHead, fixHead, fin } = grantedFixCheck();
|
||||
expect(lineOf(fin().stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
|
||||
expect(run(f, 'gstack-approvals', ['rule', '--finding', GATE, '--head', passHead, '--reason', REASON, '--words', WORDS, '--by', BY]).status).toBe(0);
|
||||
expect(lineOf(fin().stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
|
||||
expect(gate(f, GATE, f.git('merge-base', 'main', 'HEAD').stdout.trim()).status).toBe(0);
|
||||
const early = fin();
|
||||
expect(lineOf(early.stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
|
||||
expect(dispositionLines(early.stdout)).toEqual([expect.stringMatching(/; not applied\)$/)]);
|
||||
expect(gate(f, GATE, passHead).status).toBe(0);
|
||||
const r = fin();
|
||||
expect(lineOf(r.stdout, 'FIX_CHECK')).toStartWith(`FIX_CHECK: reviewed src/x.ts,tests/balance.test.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)}`);
|
||||
expect(dispositionLines(r.stdout)).toEqual([
|
||||
expect.stringMatching(/; not applied\)$/),
|
||||
expect.stringMatching(new RegExp(`^GATE_DISPOSITION: ${GATE} \\[gate ${GATE_ID}\\] at ${passHead.slice(0, 12)} by Founderstar Captain \\(FOUN-1\\) at [^ ]+Z: ${REASON} — "Do it" \\(approval a-[0-9a-f]{12}\\)$`)),
|
||||
]);
|
||||
});
|
||||
|
||||
// Value: protects=nothing dispositioned is hidden (R73): the receipt records every gate disposition, write refuses a PR body
|
||||
// without each line word for word, and READY prints it; fails_when=write drops it or accepts a body without it;
|
||||
// seam=gstack-ship-receipt write and readyTailLines
|
||||
test('the receipt records every disposition, write refuses a body without its line, and READY prints it', () => {
|
||||
const { f, head } = firstPass();
|
||||
expect(gate(f, GATE, head).status).toBe(0);
|
||||
const line = run(f, 'gstack-approvals', ['dispositions']).stdout.trim();
|
||||
const body = path.join(f.root, 'body.md');
|
||||
const write = () => f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '906', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], { GSTACK_APPROVALS_MIRROR: '0' });
|
||||
fs.writeFileSync(body, '## Pre-Landing Review\n\nGate edits: 1 listed, 1 read, 1 findings — 1 kept, 0 restored, 0 open\n');
|
||||
const short = write();
|
||||
expect([short.status, short.stderr]).toEqual([EXIT.fail, expect.stringContaining(`does not list gate disposition ${JSON.stringify(line)}`)]);
|
||||
fs.writeFileSync(body, `## Pre-Landing Review\n\n${line}\n`);
|
||||
const ok = write();
|
||||
expect(ok.status, ok.stderr).toBe(EXIT.ok);
|
||||
const receipt = extractReceipt(ok.stdout);
|
||||
expect(receipt.errors).toEqual([]);
|
||||
expect(receipt.receipt?.gate_dispositions).toEqual([{ finding: GATE, gate_id: GATE_ID, head, by: BY, reason: REASON, words: WORDS, at: expect.stringMatching(/Z$/), approval: expect.stringMatching(/^a-[0-9a-f]{12}$/) }]);
|
||||
expect(readyTailLines(receipt.receipt).filter(l => l.startsWith('GATE_DISPOSITION: '))).toEqual([`GATE_DISPOSITION: ${line.slice('Gate disposition: '.length)}`]);
|
||||
});
|
||||
});
|
||||
|
||||
// Value: protects=the GSTA-518 audit's lane-side paths (B1 sibling writer, N1 lock, N2 undefined gate id, N4 malformed rows, N5
|
||||
// skipped --body) are refused or ignored; fails_when=a sibling worktree records while the branch's /ship run is open, a row
|
||||
// missing a writer field or edited after writing closes the finding, --gate-id undefined records, or write skips the body check;
|
||||
// seam=gstack-approvals gate, gstack-review-read and gstack-ship-receipt write; no_claim=a well-formed row a same-user lane
|
||||
// forges (the lander's --accept-gate-disposition is that boundary, test/ship-queue-land-direct.test.ts)
|
||||
describe('a disposition the lane makes for itself is refused or ignored (GST-2491)', () => {
|
||||
const store = (f: ShipFx) => locate({ cwd: f.work, env: { ...process.env, GSTACK_STATE_ROOT: f.state, GSTACK_HOME: f.state, GSTACK_APPROVALS_MIRROR: '0' }, branch: 'feat' }) as StoreLocation;
|
||||
|
||||
test('a recorder name is trimmed and one line, and a parked run whose lease ended no longer refuses', () => {
|
||||
const { f, head } = firstPass();
|
||||
const lines = gate(f, GATE, head, { by: 'Founderstar\nCaptain' });
|
||||
expect([lines.status, lines.stderr]).toEqual([EXIT.usage, expect.stringContaining('one line')]);
|
||||
const marker = path.resolve(f.work, f.git('rev-parse', '--git-path', 'gstack-ship-run').stdout.trim());
|
||||
fs.writeFileSync(marker, 'ship-906-parked\n');
|
||||
const spaced = gate(f, GATE, head, { by: ` ${BY} ` });
|
||||
expect(spaced.status, spaced.stderr).toBe(0);
|
||||
expect(run(f, 'gstack-approvals', ['dispositions']).stdout).toContain(`by ${BY} at`);
|
||||
expect(read(f).review_freshness.status).toBe('CURRENT');
|
||||
});
|
||||
|
||||
test('a sibling worktree cannot record while the branch has an open /ship run, and records once it closes', () => {
|
||||
const { f, head } = firstPass();
|
||||
const marker = openRun(f);
|
||||
const sib = path.join(f.root, 'sib');
|
||||
expect(f.git('worktree', 'add', '-q', '-b', 'owner-side', sib, 'HEAD').status).toBe(0);
|
||||
const fromSib = () => spawnSync(path.join(BIN, 'gstack-approvals'), ['gate', '--branch', 'feat', '--finding', GATE, '--gate-id', GATE_ID, '--head', head, '--reason', REASON, '--words', WORDS, '--by', BY],
|
||||
{ cwd: sib, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: f.state, GSTACK_HOME: f.state, GSTACK_APPROVALS_MIRROR: '0', GIT_CONFIG_GLOBAL: path.join(f.root, 'gitconfig') } });
|
||||
const refused = fromSib();
|
||||
expect([refused.status, refused.stderr]).toEqual([EXIT.usage, expect.stringContaining(`/ship run ship-906-3a27393 is open in the worktree ${f.work} (branch feat)`)]);
|
||||
expect(read(f).review_freshness.status).toBe('UNVERIFIED');
|
||||
fs.rmSync(marker);
|
||||
const recorded = fromSib();
|
||||
expect(recorded.status, recorded.stderr).toBe(0);
|
||||
expect(read(f).review_freshness.status).toBe('CURRENT');
|
||||
});
|
||||
|
||||
test('a raw row missing a writer field, edited after writing, or for another branch closes nothing; the written row does', () => {
|
||||
const { f, head } = firstPass();
|
||||
expect(gate(f, GATE, head).status).toBe(0);
|
||||
const file = store(f).file;
|
||||
const good = JSON.parse(fs.readFileSync(file, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect([good.source, good.digest]).toEqual(['gate', expect.stringMatching(/^sha256:[0-9a-f]{64}$/)]);
|
||||
const { digest: _d, ...noDigest } = good, { by: _b, ...noBy } = good, { recorded_at: _r, ...noTime } = good;
|
||||
for (const bad of [noDigest, noBy, noTime, { ...good, source: 'manual' }, { ...good, words: 'Do it all' }, { ...good, branch: 'other' }, { ...good, id: 'gate-1' }, { ...good, ask: good.ask.replace(REASON, 'any gate change') }]) {
|
||||
fs.writeFileSync(file, JSON.stringify(bad) + '\n');
|
||||
expect([read(f).review_freshness.status, run(f, 'gstack-approvals', ['dispositions']).stdout]).toEqual(['UNVERIFIED', 'GATE_DISPOSITIONS: none\n']);
|
||||
}
|
||||
fs.writeFileSync(file, JSON.stringify(good) + '\n');
|
||||
expect(read(f).review_freshness.status).toBe('CURRENT');
|
||||
});
|
||||
|
||||
test('a literal undefined gate id is refused, and an id-less gate finding stays open even under a digest-correct row', () => {
|
||||
const { f, head } = firstPass({ gate_id: undefined });
|
||||
for (const gateId of ['undefined', 'null']) {
|
||||
const lit = gate(f, GATE, head, { gateId });
|
||||
expect([lit.status, lit.stderr]).toEqual([EXIT.usage, expect.stringContaining('--gate-id')]);
|
||||
}
|
||||
const row = buildRow({ ...gateDispositionRow(GATE, 'undefined', head, REASON), words: WORDS, by: BY, source: GATE_SOURCE }, 'feat') as ApprovalRow;
|
||||
fs.mkdirSync(path.dirname(store(f).file), { recursive: true });
|
||||
fs.appendFileSync(store(f).file, JSON.stringify({ ...row, digest: gateDispositionDigest(row as unknown as Record<string, unknown>) }) + '\n');
|
||||
expect([read(f).review_freshness.status, read(f).findings[0].action]).toEqual(['UNVERIFIED', 'open']);
|
||||
});
|
||||
|
||||
test('write refuses a receipt without --body while a disposition applies at HEAD', () => {
|
||||
const { f, head } = firstPass();
|
||||
expect(gate(f, GATE, head).status).toBe(0);
|
||||
const w = f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '906', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended'], { GSTACK_APPROVALS_MIRROR: '0' });
|
||||
expect([w.status, w.stderr]).toEqual([EXIT.fail, expect.stringContaining('so write needs --body <the PR body>')]);
|
||||
});
|
||||
|
||||
// Value: protects=a branch whose disposition row names a commit a rewrite and gc removed still ships (the 0a9a0c4 review's
|
||||
// blocker): in a full clone that row is not applied; fails_when=git's exit 128 on the missing head strands write for good
|
||||
test('write records an explicit empty gate_dispositions, and a row whose head a rewrite and gc removed is not applied', () => {
|
||||
const { f, head } = firstPass();
|
||||
expect(gate(f, GATE, head).status).toBe(0);
|
||||
for (const args of [['commit', '-q', '--amend', '-m', 'feat rewritten'], ['push', '-q', '-f', 'origin', 'HEAD:refs/heads/feat'], ['reflog', 'expire', '--expire=now', '--all'], ['gc', '-q', '--prune=now']]) expect(f.git(...args).status).toBe(0);
|
||||
for (const args of [['reflog', 'expire', '--expire=now', '--all'], ['gc', '-q', '--prune=now']]) expect(f.git('--git-dir', f.origin, ...args).status).toBe(0);
|
||||
expect([f.git('cat-file', '-e', `${head}^{commit}`).status, f.git('--git-dir', f.origin, 'cat-file', '-e', `${head}^{commit}`).status]).not.toContain(0);
|
||||
const body = path.join(f.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Pre-Landing Review\n\nNo issues found.\n');
|
||||
const ok = f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '906', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], { GSTACK_APPROVALS_MIRROR: '0' });
|
||||
expect(ok.status, ok.stderr).toBe(EXIT.ok);
|
||||
expect(extractReceipt(ok.stdout).receipt?.gate_dispositions).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ import { gitIn } from '../crew/scripts/train-gate';
|
||||
import type { GhRunner } from '../crew/scripts/recover-receipts';
|
||||
import { TRAIN_BLOCK } from './helpers/ship-queue-train-fixture';
|
||||
import { CHECK_IDS } from '../lib/pregate/index';
|
||||
import { appliedGateDispositions, gateAcceptance, GATE_SOURCE, gateDispositionDigest, gateDispositionRow, gateDispositionText, type GateDisposition } from '../lib/gate-dispositions';
|
||||
import { buildRow, locate, type ApprovalRow, type StoreLocation } from '../lib/approval-record';
|
||||
|
||||
const REQUIRED = ['free-tests', 'Check VERSION is not stale vs queue'];
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d', required_checks: REQUIRED }, merge: 'direct', min_gstack_version: MERGE_KEY_VERSION };
|
||||
@@ -1234,3 +1236,193 @@ describe('the backstop rewrites a freeze by compare-and-swap (GST-1354)', () =>
|
||||
expect(freezeOf(fx).cleared_by).toBe(`repair #7; backstop 703 green on ${green.sha}`);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
// Value: protects=the authority for an owner gate disposition is the lander's explicit acceptance, re-derived from the approval
|
||||
// store at the pin (GSTA-518 audit B1, N5): a row the lane wrote itself, a receipt or PR body that hides or invents one, and an
|
||||
// accepted id that names nothing all refuse, and verify --pin agrees with land line for line; fails_when=land trusts the
|
||||
// receipt, merges an unaccepted disposition, or verify and land print different GATE_DISPOSITION lines; seam=pinAcceptance
|
||||
// (lib/ship-queue-land-direct.ts) over gateAcceptance (lib/gate-dispositions.ts); no_claim=who wrote a well-formed row
|
||||
describe('land --pin and verify --pin accept owner gate dispositions (GST-2491)', () => {
|
||||
const F = 'tests/balance.test.ts:3:gate-integrity', G = '69c7c3432501', REASON = 'fed-careless gate allows 6 of 48 seeds';
|
||||
const prep = () => { const fx = setup(); fx.env.GSTACK_APPROVALS_MIRROR = '0'; return fx; };
|
||||
const storeEnv = (fx: Fx) => ({ ...process.env, ...fx.repo.env, ...fx.env });
|
||||
const record = (fx: Fx, pr: string, head: string) => runBin(fx.repo, 'gstack-approvals', ['gate', '--branch', 'pr-' + pr, '--finding', F, '--gate-id', G, '--head', head, '--reason', REASON, '--words', 'Do it', '--by', 'Founderstar Captain (FOUN-1)'], fx.env);
|
||||
const applied = (fx: Fx, pr: string, head: string) => appliedGateDispositions(fx.repo.work, { branch: 'pr-' + pr, pin: head, env: storeEnv(fx) }).applied;
|
||||
const bodyWith = (pr: string, head: string, ds: GateDisposition[], lines = ds) => shippedBody(pr, head, ds.length ? { gate_dispositions: ds } : {}) + lines.map(d => `\nGate disposition: ${gateDispositionText(d)}\n`).join('');
|
||||
const setBody = (fx: Fx, pr: string, body: string) => fx.write(d => { d.prs[pr].body = body; });
|
||||
const verify = (fx: Fx, pr: string, pin: string, extra: string[] = []) => runBin(fx.repo, 'gstack-ship-queue', ['verify', '--pr', pr, '--pin', pin, '--base', 'main', ...extra], fx.env);
|
||||
const gateLines = (out: string) => out.split('\n').filter(l => l.startsWith('GATE_DISPOSITION'));
|
||||
const why = (err: string) => err.replace(/^gstack-ship-queue (?:land|verify): /m, '');
|
||||
|
||||
test('end to end: the owner records, the unattended review reads CURRENT, ready prints it, land refuses until the lander accepts it, and verify agrees', () => {
|
||||
const fx = prep();
|
||||
const head = openPr(fx, '41', { ready: false });
|
||||
fx.repo.git('checkout', '-q', 'pr-41');
|
||||
const lane = { ...fx.env, GSTACK_SESSION_KIND: 'unattended' };
|
||||
const start = runBin(fx.repo, 'gstack-review-log', ['--start', 'review'], lane);
|
||||
expect(start.status, start.err).toBe(0);
|
||||
const row = { skill: 'review', timestamp: '2026-11-11T20:30:13Z', via: 'ship', review_scope: 'frozen', pass: 1, range_base: null, completed: true, converged: true, status: 'issues_found', issues_found: 1, critical: 1, informational: 0,
|
||||
findings: [{ fingerprint: F, severity: 'CRITICAL', locus: 'diff', kind: 'defect', blocking: true, raised_by: 'core', gate: 'RH-15', gate_id: G, action: 'kept', reason: 'owner approved' }] };
|
||||
const logged = runBin(fx.repo, 'gstack-review-log', [JSON.stringify(row), '--finish', start.out.trim()], lane);
|
||||
expect(logged.status, logged.err).toBe(0);
|
||||
const review = () => runBin(fx.repo, 'gstack-review-read', [], fx.env).out.split('\n---CONFIG---')[0]!.split('\n')
|
||||
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review' ? [v] : []; } catch { return []; } }).at(-1);
|
||||
expect([review().review_freshness.status, review().findings[0].action]).toEqual(['UNVERIFIED', 'open']);
|
||||
const rec = record(fx, '41', head);
|
||||
expect(rec.status, rec.err).toBe(0);
|
||||
const id = rec.out.split(' ')[1]!;
|
||||
expect([review().review_freshness.status, review().findings[0].action, review().findings[0].disposition]).toEqual(['CURRENT', 'kept', id]);
|
||||
const ds = applied(fx, '41', head);
|
||||
expect(ds.map(d => d.approval)).toEqual([id]);
|
||||
setBody(fx, '41', bodyWith('41', head, ds));
|
||||
const pregate = path.join(fx.repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head, verdict: 'pass', checks: FULL_CHECKS }));
|
||||
const ready = runBin(fx.repo, 'gstack-ship-queue', ['ready', '--pr', '41', '--pregate', pregate, '--base', 'main'], fx.env);
|
||||
expect(ready.status, ready.out + ready.err).toBe(EXIT.ok);
|
||||
expect(gateLines(ready.out)).toEqual([`GATE_DISPOSITION: ${gateDispositionText(ds[0]!)}`, expect.stringMatching(new RegExp(`^GATE_DISPOSITIONS: applied=${id} store=`))]);
|
||||
fx.repo.git('checkout', '-q', '--detach', 'origin/main');
|
||||
const bare = land(fx, '41', head);
|
||||
expect([bare.status, bare.err]).toEqual([EXIT.refused, expect.stringContaining(`disposition ${id} is not accepted`)]);
|
||||
expect(bare.err).toContain('(QUEUE_GATE_DISPOSITION)');
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
const checked = verify(fx, '41', head);
|
||||
expect([checked.status, why(checked.err)]).toEqual([EXIT.refused, why(bare.err)]);
|
||||
expect(gateLines(checked.out)).toEqual(gateLines(bare.out));
|
||||
const ok = verify(fx, '41', head, ['--accept-gate-disposition', id]);
|
||||
expect(ok.status, ok.out + ok.err).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain(`VERIFY_PIN: ok #41 at ${head.slice(0, 12)}`);
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
const landed = land(fx, '41', head, ['--accept-gate-disposition', id]);
|
||||
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
|
||||
expect(landed.out).toContain('MERGED: #41 as ');
|
||||
expect(gateLines(landed.out)).toEqual(gateLines(ok.out));
|
||||
expect(gateLines(landed.out)[1]).toContain(`accepted=${id}`);
|
||||
}, 180_000);
|
||||
|
||||
test('a row the lane appends to the store by hand, digest and all, still needs the lander: land refuses and prints it, a wrong id refuses, and the accepted id lands', () => {
|
||||
const fx = prep();
|
||||
const head = openPr(fx, '42');
|
||||
const loc = locate({ cwd: fx.repo.work, env: storeEnv(fx), branch: 'pr-42' }) as StoreLocation;
|
||||
const forged = buildRow({ ...gateDispositionRow(F, G, head, 'the lane says the owner agreed'), words: 'Do it', by: 'Owner', source: GATE_SOURCE }, 'pr-42') as ApprovalRow;
|
||||
fs.mkdirSync(path.dirname(loc.file), { recursive: true });
|
||||
fs.appendFileSync(loc.file, JSON.stringify({ ...forged, digest: gateDispositionDigest(forged as unknown as Record<string, unknown>) }) + '\n');
|
||||
const ds = applied(fx, '42', head);
|
||||
expect(ds.map(d => d.approval)).toEqual([forged.id]);
|
||||
setBody(fx, '42', bodyWith('42', head, ds));
|
||||
const line = `GATE_DISPOSITION: ${gateDispositionText(ds[0]!)}`;
|
||||
const bare = land(fx, '42', head);
|
||||
expect([bare.status, gateLines(bare.out)[0], bare.err]).toEqual([EXIT.refused, line, expect.stringContaining(`disposition ${forged.id} is not accepted`)]);
|
||||
const wrong = land(fx, '42', head, ['--accept-gate-disposition', forged.id, '--accept-gate-disposition', 'a-000000000000']);
|
||||
expect([wrong.status, wrong.err]).toEqual([EXIT.refused, expect.stringContaining('--accept-gate-disposition a-000000000000 names no disposition the store applies')]);
|
||||
expect(verify(fx, '42', head, ['--accept-gate-disposition', 'a-000000000000']).status).toBe(EXIT.refused);
|
||||
expect(land(fx, '42', head, ['--accept-gate-disposition', 'undefined']).status).toBe(EXIT.usage);
|
||||
const landed = land(fx, '42', head, ['--accept-gate-disposition', forged.id]);
|
||||
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
|
||||
expect(gateLines(landed.out)[0]).toBe(line);
|
||||
}, 120_000);
|
||||
|
||||
test('the receipt omitting a disposition, the body dropping its line, and the receipt listing one the store lacks refuse land and verify alike', () => {
|
||||
const fx = prep();
|
||||
const head = openPr(fx, '43');
|
||||
const rec = record(fx, '43', head);
|
||||
expect(rec.status, rec.err).toBe(0);
|
||||
const id = rec.out.split(' ')[1]!;
|
||||
const ds = applied(fx, '43', head);
|
||||
const invented = { ...ds[0]!, approval: 'a-111111111111' };
|
||||
for (const [body, problem] of [
|
||||
[bodyWith('43', head, [], ds), 'the receipt has no gate_dispositions field'],
|
||||
[shippedBody('43', head, { gate_dispositions: [] }) + `\nGate disposition: ${gateDispositionText(ds[0]!)}\n`, `the receipt omits applied disposition ${id}`],
|
||||
[bodyWith('43', head, ds, []), 'the PR body lacks the line "Gate disposition: '],
|
||||
[bodyWith('43', head, [...ds, invented]), 'the receipt lists disposition a-111111111111, which the store'],
|
||||
] as const) {
|
||||
setBody(fx, '43', body);
|
||||
const l = land(fx, '43', head, ['--accept-gate-disposition', id, '--dry-run']);
|
||||
const v = verify(fx, '43', head, ['--accept-gate-disposition', id]);
|
||||
expect([l.status, l.err]).toEqual([EXIT.refused, expect.stringContaining(problem)]);
|
||||
expect([v.status, why(v.err), gateLines(v.out)]).toEqual([EXIT.refused, why(l.err), gateLines(l.out)]);
|
||||
}
|
||||
setBody(fx, '43', bodyWith('43', head, ds));
|
||||
expect(verify(fx, '43', head, ['--accept-gate-disposition', id]).status).toBe(EXIT.ok);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a merger that reads another state root, or a store that lost the row after the receipt was written, never lands a
|
||||
// PR whose review an owner disposition cleared with zero accepted ids (the ab73c3f audit's B1); fails_when=a receipt id the store
|
||||
// lacks, or a field-less receipt beside a row, reads as nothing to accept; seam=gateAcceptance via land and verify
|
||||
test('another state root at merge, a row removed after the receipt, or a field-less receipt beside a row refuses; an explicit [] with an empty store lands', () => {
|
||||
const fx = prep();
|
||||
const head = openPr(fx, '44');
|
||||
const rec = record(fx, '44', head);
|
||||
expect(rec.status, rec.err).toBe(0);
|
||||
const id = rec.out.split(' ')[1]!;
|
||||
const ds = applied(fx, '44', head);
|
||||
setBody(fx, '44', bodyWith('44', head, ds));
|
||||
const other = { ...fx.env, GSTACK_STATE_ROOT: path.join(fx.repo.root, 'merger-state'), GSTACK_HOME: path.join(fx.repo.root, 'merger-state') };
|
||||
const elsewhere = runBin(fx.repo, 'gstack-ship-queue', ['verify', '--pr', '44', '--pin', head, '--base', 'main', '--accept-gate-disposition', id], other);
|
||||
expect([elsewhere.status, elsewhere.err]).toEqual([EXIT.refused, expect.stringContaining('may read another GSTACK_STATE_ROOT or approvals mirror')]);
|
||||
const landElsewhere = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '44', '--pin', head, '--base', 'main', '--dry-run', '--accept-gate-disposition', id], other);
|
||||
expect([landElsewhere.status, why(landElsewhere.err)]).toEqual([EXIT.refused, why(elsewhere.err)]);
|
||||
const store = (locate({ cwd: fx.repo.work, env: storeEnv(fx), branch: 'pr-44' }) as StoreLocation).file;
|
||||
const kept = fs.readFileSync(store, 'utf8');
|
||||
fs.rmSync(store);
|
||||
const removed = land(fx, '44', head, ['--dry-run', '--accept-gate-disposition', id]);
|
||||
expect([removed.status, removed.err]).toEqual([EXIT.refused, expect.stringContaining(`the receipt lists disposition ${id}, which the store at`)]);
|
||||
expect(verify(fx, '44', head, ['--accept-gate-disposition', id]).status).toBe(EXIT.refused);
|
||||
fs.writeFileSync(store, kept);
|
||||
setBody(fx, '44', shippedBody('44', head) + `\nGate disposition: ${gateDispositionText(ds[0]!)}\n`);
|
||||
const fieldless = land(fx, '44', head, ['--dry-run', '--accept-gate-disposition', id]);
|
||||
expect([fieldless.status, fieldless.err]).toEqual([EXIT.refused, expect.stringContaining('the receipt has no gate_dispositions field')]);
|
||||
expect(verify(fx, '44', head, ['--accept-gate-disposition', id]).status).toBe(EXIT.refused);
|
||||
const pin = openPr(fx, '45');
|
||||
setBody(fx, '45', shippedBody('45', pin, { gate_dispositions: [] }));
|
||||
expect(verify(fx, '45', pin).status).toBe(EXIT.ok);
|
||||
const landed = land(fx, '45', pin);
|
||||
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
|
||||
expect(gateLines(landed.out)).toEqual([]);
|
||||
}, 180_000);
|
||||
|
||||
test('a git error while deriving the dispositions refuses instead of reading as not applied (N1)', () => {
|
||||
const fx = prep();
|
||||
const head = openPr(fx, '46');
|
||||
expect(record(fx, '46', head).status).toBe(0);
|
||||
const r = gateAcceptance({ cwd: fx.repo.work, branch: 'pr-46', pin: 'f'.repeat(40), body: '', receipt: { gate_dispositions: [] } as any, env: storeEnv(fx) });
|
||||
expect(r.failure?.message).toContain(`the pin ${'f'.repeat(12)} is not a commit in this clone`);
|
||||
expect(r.failure).toMatchObject({ code: 'QUEUE_GATE_DISPOSITION', message: expect.stringContaining('cannot be derived') });
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a row whose head a rewrite and gc removed never strands land, verify or ready (the 0a9a0c4 review's blocker),
|
||||
// while a shallow clone, which cannot prove ancestry, refuses; fails_when=git's exit 128 on the missing head refuses for good,
|
||||
// or a shallow clone reads an unprovable row as not applied; seam=appliedGateDispositions via ready, verify and land
|
||||
test('a row whose head a rewrite and gc removed is not applied: ready, verify and land proceed; a shallow clone refuses and says to deepen it', () => {
|
||||
const fx = prep();
|
||||
const old = openPr(fx, '47', { ready: false });
|
||||
expect(record(fx, '47', old).status).toBe(0);
|
||||
fx.repo.git('checkout', '-q', 'pr-47');
|
||||
fx.repo.git('commit', '-q', '--amend', '-m', 'pr 47 rewritten');
|
||||
fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-47');
|
||||
const head = fx.repo.git('rev-parse', 'HEAD');
|
||||
for (const dir of [['-C', fx.repo.work], ['--git-dir', fx.repo.origin]]) {
|
||||
fx.repo.git(...dir, 'reflog', 'expire', '--expire=now', '--all');
|
||||
fx.repo.git(...dir, 'gc', '-q', '--prune=now');
|
||||
}
|
||||
expect([spawnCheck(fx.repo.work, old), spawnCheck(fx.repo.origin, old), spawnCheck(fx.repo.work, head)]).toEqual([false, false, true]);
|
||||
expect(applied(fx, '47', head)).toEqual([]);
|
||||
setBody(fx, '47', shippedBody('47', head, { gate_dispositions: [] }));
|
||||
const pregate = path.join(fx.repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head, verdict: 'pass', checks: FULL_CHECKS }));
|
||||
const ready = runBin(fx.repo, 'gstack-ship-queue', ['ready', '--pr', '47', '--pregate', pregate, '--base', 'main'], fx.env);
|
||||
expect(ready.status, ready.out + ready.err).toBe(EXIT.ok);
|
||||
expect(gateLines(ready.out)).toEqual([]);
|
||||
fx.repo.git('checkout', '-q', '--detach', 'origin/main');
|
||||
const ok = verify(fx, '47', head);
|
||||
expect(ok.status, ok.out + ok.err).toBe(EXIT.ok);
|
||||
const landed = land(fx, '47', head);
|
||||
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
|
||||
expect(landed.out).toContain('MERGED: #47 as ');
|
||||
const shallow = path.join(fx.repo.root, 'shallow');
|
||||
fx.repo.git('clone', '-q', '--depth', '1', '--branch', 'pr-47', 'file://' + fx.repo.origin, shallow);
|
||||
fx.repo.git('-C', shallow, 'config', 'remote.origin.url', fx.repo.git('config', 'remote.origin.url'));
|
||||
const r = gateAcceptance({ cwd: shallow, branch: 'pr-47', pin: head, body: '', receipt: { gate_dispositions: [] } as any, env: storeEnv(fx) });
|
||||
expect([r.failure?.code, r.failure?.message]).toEqual(['QUEUE_GATE_DISPOSITION', expect.stringContaining('this clone is shallow')]);
|
||||
expect(r.failure?.message).toContain('deepen the clone (git fetch --unshallow)');
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 105,
|
||||
"why": "gate dispositions (GST-2491): the PR body carries each Gate disposition line"
|
||||
}
|
||||
Reference in new issue
Block a user