Files
gstack/crew/test/ship-queue-ready-notes-push.test.ts
T
Garry Tanandcapy-ai[bot] dfdaea2efd Make ship-queue ready refuse when its READY note doesn't reach origin (GST-2242) (#4254)
* Make ship-queue ready refuse when its READY note doesn't reach origin (GST-2242)

ready exited 0 and printed "notes pushed" even when the READY note never reached origin, so land refused the pin with QUEUE_NOT_READY (#4245). The notes writer now reads origin's ref back after each push, and train ready refuses with QUEUE_READY_NOTE_UNPUSHED when origin still lacks the note after three tries.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Name the changelog fragment for the branch (GST-2242)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Regenerate the doctor component list for the new ready result code (GST-2242)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say that ready's notes check covers train policies, and how to rerun it with its gate flags (GST-2242)

Review-Finding: p1/red-team#2 reader=red-team
Review-Finding: p1/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Skip the ready notes-push test on Windows, where the gh shim can't run (GST-2242)

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>
2026-11-10 00:54:27 +00:00

83 lines
5.4 KiB
TypeScript

/**
* `gstack-ship-queue ready` under a train policy records its READY receipt on
* origin's refs/notes/gstack-trains, which `land --pin` reads. When that push
* never reaches origin, ready must refuse instead of printing "notes pushed"
* and exiting 0 (reports/GSTA-461.md item 19: #4245's land refused
* QUEUE_NOT_READY because origin had no note).
*/
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, runBin } from '../../test/helpers/restamp-fixture';
import { line, openPr, queue, repoKey, trainFixture, type TrainFixture } from '../../test/helpers/ship-queue-train-fixture';
const NOTES = 'refs/notes/gstack-trains';
const fixtures: TrainFixture[] = [];
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
function readyFixture() {
const fx = trainFixture();
fixtures.push(fx);
const head = openPr(fx, '1');
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
return { fx, head, pregate };
}
function hook(fx: TrainFixture, name: 'pre-receive' | 'post-receive', body: string): void {
fs.writeFileSync(path.join(fx.repo.origin, 'hooks', name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
}
const remoteNotes = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin', NOTES).split(/\s+/)[0] ?? '';
const localNotes = (fx: TrainFixture) => fx.repo.git('rev-parse', '--verify', '--quiet', NOTES);
describe('ready refuses a READY note that never reached origin', () => {
// Value: protects=a lane never reports READY while origin lacks the note land reads; fails_when=ready ignores persist's pushed:false and exits 0 with "notes pushed"; why_new=no case pushes ready's note to a remote that rejects it; seam=a pre-receive hook in the fixture's bare origin; no_claim=GitHub's own rejection messages
test('origin rejects refs/notes/*: ready exits refused with QUEUE_READY_NOTE_UNPUSHED and prints no success', () => {
const { fx, head, pregate } = readyFixture();
hook(fx, 'pre-receive', 'while read old new ref; do case "$ref" in refs/notes/*) echo "notes refused by fixture" >&2; exit 1;; esac; done; exit 0');
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toContain('(QUEUE_READY_NOTE_UNPUSHED)');
expect(r.out).not.toContain('notes pushed');
expect(line(r.out, 'GSTACK_RESULT:')).toContain('status=refused');
expect(line(r.out, 'NEXT:')).toContain('gstack-ship-queue ready --pr 1 --no-screen');
expect(remoteNotes(fx)).toBe('');
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`))).toBe(true);
}, 120_000);
// Value: protects=success is printed only after origin is read back holding the note; fails_when=a push that exits 0 without moving origin's ref passes as pushed; why_new=the case above covers a push git reports as failed; seam=a post-receive hook that deletes the ref after accepting it, standing in for a proxy that drops the update; no_claim=which proxy dropped #4245's push
test('a push that exits 0 but leaves origin without the note is refused too', () => {
const { fx, pregate } = readyFixture();
hook(fx, 'post-receive', `git update-ref -d ${NOTES} 2>/dev/null; exit 0`);
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toContain('(QUEUE_READY_NOTE_UNPUSHED)');
expect(r.out).not.toContain('notes pushed');
expect(remoteNotes(fx)).toBe('');
}, 120_000);
// Value: protects=the normal push still succeeds and origin holds exactly the local notes tip; fails_when=the confirmation read refuses a push that did land; why_new=hold case for the two refusals above; seam=none; no_claim=a real GitHub remote
test('hold: a normal push succeeds, prints notes pushed, and origin holds the local notes tip', () => {
const { fx, pregate } = readyFixture();
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
expect(r.status).toBe(EXIT.ok);
expect(line(r.out, 'READY_RECEIPT:')).toEndWith(`(notes pushed to origin ${NOTES})`);
expect(remoteNotes(fx)).toBe(localNotes(fx));
}, 120_000);
// Value: protects=rerunning ready once origin accepts notes pushes the kept receipt and succeeds; fails_when=the refusal's NEXT command cannot recover; why_new=the documented fix for QUEUE_READY_NOTE_UNPUSHED; seam=the pre-receive hook removed between runs; no_claim=none beyond the fixture
test('rerunning ready after origin accepts notes again pushes the note and succeeds', () => {
const { fx, pregate } = readyFixture();
hook(fx, 'pre-receive', 'exit 1');
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.refused);
fs.rmSync(path.join(fx.repo.origin, 'hooks', 'pre-receive'));
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
expect(again.status).toBe(EXIT.ok);
expect(remoteNotes(fx)).toBe(localNotes(fx));
}, 120_000);
});