mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(release-cut,ship-queue): the weekly release accepts a train-gated commit, and five lane frictions seen in operation scripts/release-cut.ts gates a main commit either by a successful backstop check run or by a green/merging/merged train record in refs/notes/gstack-trains whose merged[] names its sha or tree (CANDIDATE: ... gated by train <nonce>); a red check, a running run, the required check and the freeze are judged as before. --required-check, --backstop-check and --notes-ref make the names configurable. lib/issue-links.ts reads (name #N[, #M]), (name PR #N) and (name issue #N) as cross-repo references. lib/gstack-post.ts allowlists an 11-digit run id after a run word or chained after one, and pr-body merges by ## section into the PR's current body (--replace overwrites). gstack-pregate mirrors each record under <state root>/pregate/by-tree/<tree>.json and ready reads the mirror for its tree when the checkout's record is missing or for another tree. docs/troubleshooting.md gains #redact-run-id-as-phone, #gate-error-no-base and #gstack-post-pr-body-merge. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 1) Fence-length-aware section splitting in gstack-post pr-body (an unclosed receipt fence or a four-backtick block no longer exposes a heading to the merge), the run-id allowlist drops generic words (id, job, attempt, workflow), ready accepts a by-tree pregate mirror only from a complete run against origin/<base> under a reviewed policy and prints no home path, release-cut skips non-object notes and never substitutes train evidence for the required check when both names are equal, issue-links treats closing/cf./e.g./v1.2 as words rather than repositories. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 2) An unclosed receipt fence makes the body from that line on one section the merge never matches, so posted leading prose cannot replace it and a closed receipt before it stays a receipt; the by-tree pregate mirror must hold the tests stage (what --stage tests --remote writes) rather than every check; empty sha/tree strings in a train note are not evidence; (v1.2.3-rc1 #301) is a version word, not a repository. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CANDIDATE line names its gate; issue-links reference documents the (name #N) form Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): a PREGATE_ENVIRONMENT local record is final; no mirror stands in for it Merging origin/mainb004c06(#3480) into this branch created a semantic conflict between two individually correct parents: #3480 made verifyPregate return PREGATE_ENVIRONMENT (instead of PREGATE_INCOMPLETE) when every incomplete check was caused by the environment, and this branch's by-tree mirror reader in ready() treated only PREGATE_LANE_FAILED and PREGATE_INCOMPLETE as final. Merged, a local record whose secrets scan never finished fell through to a same-tree tests-only mirror and ready passed without a secrets scan, against the rule this branch documents ("a record whose own checks failed or stayed incomplete is never overridden"). ready() now keeps a PREGATE_ENVIRONMENT record final as well. Regression in test/gstack-ship-queue.test.ts ("one READY block ..."): an environment-incomplete secrets record beside a passing same-tree mirror is refused with (PREGATE_ENVIRONMENT) and no "from the state root" suffix. Red on the bare merge28b64d7(expected exit 3, received 0), green with this fix. Found by the pass-4 delta review of the merge. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.262.0 stamp 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>
125 lines
8.5 KiB
TypeScript
125 lines
8.5 KiB
TypeScript
/**
|
|
* C8 invalidation: pregate.json is evidence only while it is current. A tree
|
|
* that moved after the run, a failed or incomplete check, or a requires-remote
|
|
* obligation without its receipt all reject publication — through
|
|
* `gstack-pregate verify` and through `gstack-ship-receipt write --pregate`,
|
|
* which is what /ship Step 18 calls. Pinned on result codes and exit codes.
|
|
*/
|
|
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, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
|
|
|
const repos: FixtureRepo[] = [];
|
|
const fixture = () => { const r = makeFixtureRepo(); repos.push(r); return r; };
|
|
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
|
const run = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', ...args]);
|
|
const outFile = (repo: FixtureRepo) => path.join(repo.work, '.gstack/tmp/pregate.json');
|
|
const receipt = (repo: FixtureRepo, extra: string[] = []) => runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'interactive', '--pregate', outFile(repo), '--json', ...extra]);
|
|
|
|
describe('pregate invalidation', () => {
|
|
test('a current green run verifies and lands in the ship receipt as pregate + requires_remote; a later edit makes it PREGATE_STALE for both', () => {
|
|
const repo = fixture();
|
|
commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
|
expect(run(repo, []).status).toBe(EXIT.ok);
|
|
const v = runBin(repo, 'gstack-pregate', ['verify']);
|
|
expect(v.status).toBe(EXIT.ok);
|
|
expect(v.out).toMatch(/^PREGATE_VERIFY: ok pregate pass is current for tree [0-9a-f]{12}$/m);
|
|
const ok = receipt(repo);
|
|
expect(ok.status).toBe(EXIT.ok);
|
|
const parsed = JSON.parse(ok.out);
|
|
expect(parsed.pregate).toMatch(/^pass @[0-9a-f]{12} \(7 checks, current for [0-9a-f]{12}\)$/);
|
|
expect(parsed.requires_remote).toBeUndefined();
|
|
expect(Object.keys(parsed).indexOf('pregate')).toBeGreaterThan(Object.keys(parsed).indexOf('history') < 0 ? Object.keys(parsed).indexOf('session_kind') : Object.keys(parsed).indexOf('history'));
|
|
// The stamp (or any edit) after the run moves the tree: the record is no longer evidence.
|
|
write(repo.work, 'README.md', '# fixture 1.0.1\n');
|
|
const stale = runBin(repo, 'gstack-pregate', ['verify']);
|
|
expect(stale.status).toBe(EXIT.fail);
|
|
expect(stale.err).toContain('(PREGATE_STALE)');
|
|
expect(stale.out).toContain('PREGATE_VERIFY: refused PREGATE_STALE');
|
|
const r = receipt(repo);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.err).toMatch(/nothing written.*\(PREGATE_STALE\)/);
|
|
expect(r.out).toBe('');
|
|
// Re-running on the moved tree makes it current again.
|
|
expect(run(repo, []).status).toBe(EXIT.ok);
|
|
expect(receipt(repo).status).toBe(EXIT.ok);
|
|
}, 30_000);
|
|
|
|
test('an uncleared requires-remote obligation is PREGATE_REMOTE_UNCLEARED until `clear` records a receipt, which the ship receipt then carries', () => {
|
|
const repo = fixture();
|
|
commit(repo, { 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths', () => { /* touched */ });\n" });
|
|
expect(run(repo, []).status).toBe(EXIT.ok);
|
|
const v = runBin(repo, 'gstack-pregate', ['verify']);
|
|
expect(v.status).toBe(EXIT.fail);
|
|
expect(v.err).toContain('(PREGATE_REMOTE_UNCLEARED)');
|
|
expect(v.out).toContain('requires-remote: windows.yml/windows-shard (windows) 1 test(s) uncleared');
|
|
const r = receipt(repo);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.err).toContain('(PREGATE_REMOTE_UNCLEARED)');
|
|
expect(runBin(repo, 'gstack-pregate', ['clear', '--remote', 'nope.yml/job', '--run-url', 'https://example.invalid/runs/1']).status).toBe(EXIT.usage);
|
|
const c = runBin(repo, 'gstack-pregate', ['clear', '--remote', 'windows.yml/windows-shard', '--run-url', 'https://github.com/o/r/actions/runs/42']);
|
|
expect(c.status).toBe(EXIT.ok);
|
|
expect(c.out).toContain('PREGATE_CLEARED: windows.yml/windows-shard receipt=https://github.com/o/r/actions/runs/42 verdict=requires-remote cleared');
|
|
const persisted = JSON.parse(fs.readFileSync(outFile(repo), 'utf8'));
|
|
expect(persisted.requires_remote[0]).toMatchObject({ lane: 'windows.yml/windows-shard', receipt: 'https://github.com/o/r/actions/runs/42' });
|
|
expect(persisted.requires_remote[0].cleared_by).toMatch(/^gstack-pregate clear \d{4}-/);
|
|
expect(runBin(repo, 'gstack-pregate', ['verify']).status).toBe(EXIT.ok);
|
|
const ok = receipt(repo);
|
|
expect(ok.status).toBe(EXIT.ok);
|
|
expect(JSON.parse(ok.out)).toMatchObject({ pregate: expect.stringMatching(/^requires-remote cleared @/), requires_remote: ['windows.yml/windows-shard: https://github.com/o/r/actions/runs/42'] });
|
|
}, 30_000);
|
|
|
|
test('a failed check and an incomplete check both refuse publication; a missing or invalid pregate.json is not evidence', () => {
|
|
const repo = fixture();
|
|
commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n" });
|
|
expect(run(repo, ['--stage', 'preflight']).status).toBe(EXIT.fail);
|
|
expect(runBin(repo, 'gstack-pregate', ['verify']).err).toContain('(PREGATE_LANE_FAILED)');
|
|
expect(receipt(repo).err).toContain('(PREGATE_LANE_FAILED)');
|
|
const inc = fixture();
|
|
commit(inc, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
|
expect(runBin(inc, 'gstack-pregate', ['--base', 'main']).status).toBe(EXIT.fail);
|
|
expect(runBin(inc, 'gstack-pregate', ['verify']).err).toContain('(PREGATE_INCOMPLETE)');
|
|
expect(receipt(inc).err).toContain('(PREGATE_INCOMPLETE)');
|
|
const none = fixture();
|
|
expect(runBin(none, 'gstack-pregate', ['verify']).err).toContain('(PREGATE_STALE)');
|
|
fs.mkdirSync(path.dirname(outFile(none)), { recursive: true });
|
|
fs.writeFileSync(outFile(none), '{"schema_version": 1}\n');
|
|
const bad = receipt(none);
|
|
expect(bad.status).toBe(EXIT.fail);
|
|
expect(bad.err).toContain('(ARTIFACT_SCHEMA)');
|
|
}, 30_000);
|
|
|
|
// Value: protects=every record gstack-pregate writes (run and clear) is mirrored byte for byte to <state root>/pregate/by-tree/<tree>.json, the path
|
|
// gstack-ship-queue ready reads for a checkout that did not run the pregate, and a state root that cannot be written still leaves the record and
|
|
// the exit code untouched; fails_when=the mirror is missing, lands under another path or name, holds different bytes than the record, is not
|
|
// refreshed by clear, or an unwritable state root fails the run; why_new=the ship-queue test writes the mirror by hand and covers only the reader;
|
|
// seam=none; no_claim=a second checkout reading it (test/gstack-ship-queue.test.ts covers ready)
|
|
test('run and clear mirror the record to <state root>/pregate/by-tree/<tree>.json; an unwritable state root never fails the record', () => {
|
|
const repo = fixture();
|
|
commit(repo, { 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths', () => { /* touched */ });\n" });
|
|
expect(run(repo, []).status).toBe(EXIT.ok);
|
|
const record = fs.readFileSync(outFile(repo), 'utf8');
|
|
const tree = (JSON.parse(record) as { tree: string }).tree;
|
|
expect(tree).toMatch(/^[0-9a-f]{12,128}$/);
|
|
const mirror = path.join(repo.home, 'pregate', 'by-tree', `${tree}.json`);
|
|
expect(fs.readdirSync(path.join(repo.home, 'pregate', 'by-tree'))).toEqual([`${tree}.json`]);
|
|
expect(fs.readFileSync(mirror, 'utf8')).toBe(record);
|
|
expect(runBin(repo, 'gstack-pregate', ['clear', '--remote', 'windows.yml/windows-shard', '--run-url', 'https://github.com/o/r/actions/runs/42']).status).toBe(EXIT.ok);
|
|
const cleared = fs.readFileSync(outFile(repo), 'utf8');
|
|
expect(cleared).not.toBe(record);
|
|
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
|
|
expect(JSON.parse(fs.readFileSync(mirror, 'utf8')).requires_remote[0].receipt).toBe('https://github.com/o/r/actions/runs/42');
|
|
|
|
const blocked = path.join(repo.root, 'not-a-dir');
|
|
fs.writeFileSync(blocked, 'a file where the state root should be\n');
|
|
const unwritable = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands'], { GSTACK_STATE_ROOT: path.join(blocked, 'root') });
|
|
expect(unwritable.status, unwritable.out + unwritable.err).toBe(EXIT.ok);
|
|
expect(unwritable.err).not.toMatch(/ENOTDIR|EACCES|by-tree/);
|
|
expect(JSON.parse(fs.readFileSync(outFile(repo), 'utf8')).tree).toBe(tree);
|
|
expect(fs.existsSync(path.join(blocked, 'root'))).toBe(false);
|
|
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
|
|
}, 120_000);
|
|
});
|