diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a25ffec9..d6fc97f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ ### Changed - **`scripts/release-cut.ts`**: a commit on main's first-parent history is gated either by a successful backstop check run or by a train record (kind `train`, state `green`, `merging` or `merged`) whose `merged[]` lists its sha or its tree, the main commits `gstack-ship-queue verify --train` confirmed. Everything else stays required: the required check run succeeded, no red check run (a red backstop on a covered commit still refuses), nothing still running, no active freeze. A commit with neither is refused with the same message as before. The `CANDIDATE` line says `gated by main-backstop` or `gated by train `. The notes ref is fetched and read once (`readTrainNotes`: the freeze and the train evidence together; `activeFreeze` is folded into it). New flags `--required-check ` (default `free-tests`), `--backstop-check ` (default `main-backstop`) and `--notes-ref ` (default `refs/notes/gstack-trains`) let another repository run the script; `weekly-release.yml` is unchanged. -- **`lib/issue-links.ts`**: a parenthesised named reference, `(name #N[, #M]…)`, `(name PR #N)` or `(name issue #N)`, where the name is one token that is not a closure or reference word (fix, fixes, closes, resolves, refs, see, related, part, follow-up, deferred, PR, issue, …), is a cross-repo reference to `name` (`Refs name#N`), never a same-repo Refs line or a `fix #N` promotion candidate. `(see #12)`, `(#301)` and `Fixes #12` keep their meaning; the `owner/repo#N` form is unchanged. -- **`lib/gstack-post.ts`**: an 11-digit GitHub Actions run id right after a run word on the same line (run, runs, screen, screens, gate, gates, backstop, bisect, rerun, dispatched, dispatch, failed, passed, workflow, job, attempt, id, ids, with an optional `:` `#` `=`), and every id chained after an allowlisted one through `, `, ` and `, ` & `, ` or ` or ` / ` (a `(#1234)` between allowed), is exempt from `pii.phone.e164`. `failed 40269992077 | rerun passed 40287239206`, `screen 40269992077 (#3434) and 40275443393 (#3453)` and `gate 40323989742` post without `--confirm`. A bare 11-digit number, a 10-digit national number and `+14155551234` still report. -- **`lib/gstack-post.ts`** (`pr-body`): the body file is merged by `## ` section into the PR's current body (read with `gh pr view --json body`, or `glab mr view`, through the same runner) instead of replacing it. A section in the file replaces the same-titled section in place (case-insensitive), the ````gstack-ship-receipt```` block is a section of its own so a receipt rewrite replaces only the receipt, sections the file does not rewrite are kept byte for byte, new sections are appended after them, and a current body with no `## ` headings is kept as the leading text. One stderr line names the replaced, kept and added sections. `--replace` keeps the plain overwrite for a caller that composed the whole body. The redaction scan and the confirmation token cover the merged bytes, which are the bytes sent. A current body that cannot be read posts nothing (exit 3) and names `--replace`. -- **`lib/pregate/index.ts`**, **`lib/ship-queue.ts`**: `gstack-pregate` mirrors every record it writes to `/pregate/by-tree/.json` (best effort; a mirror failure never fails the record). `ready` reads the checkout's `.gstack/tmp/pregate.json` (or `--pregate`) first; when it is missing or was recorded for another tree, it looks the current tree up in the mirror and uses that record when it verifies, printing `PREGATE: … (from , recorded by another checkout)`. A lane whose `gstack-pregate --remote` had to run in a separate clone (a worktree cannot host it) no longer gets `PREGATE_STALE` or `missing` from its own checkout. A mirror for another tree changes nothing. +- **`lib/issue-links.ts`**: a parenthesised named reference, `(name #N[, #M]…)`, `(name PR #N)` or `(name issue #N)`, where the name is one token that is not a closure, reference, filler or version word (fix, fixes, fixing, closes, closing, resolves, addresses, implements, supersedes, refs, see, related, part, follow-up, deferred, PR, issue, bug, upstream, cf., e.g., i.e., of, to, the, v1.2, …), is a cross-repo reference to `name` (`Refs name#N`), never a same-repo Refs line or a `fix #N` promotion candidate. `(see #12)`, `(closing #12)`, `(cf. #12)`, `(#301)` and `Fixes #12` keep their meaning; the `owner/repo#N` form is unchanged. +- **`lib/gstack-post.ts`**: an 11-digit GitHub Actions run id right after a run word on the same line (run, runs, screen, screens, gate, gates, backstop, bisect, rerun, dispatched, dispatch, failed, passed, with an optional `:` `#` `=`; a generic word such as id, job, attempt or workflow exempts nothing), and every id chained after an allowlisted one through `, `, ` and `, ` & `, ` or ` or ` / ` (a `(#1234)` between allowed), is exempt from `pii.phone.e164`. `failed 40269992077 | rerun passed 40287239206`, `screen 40269992077 (#3434) and 40275443393 (#3453)` and `gate 40323989742` post without `--confirm`. A bare 11-digit number, a 10-digit national number and `+14155551234` still report. +- **`lib/gstack-post.ts`** (`pr-body`): the body file is merged by `## ` section into the PR's current body (read with `gh pr view --json body`, or `glab mr view`, through the same runner) instead of replacing it. A section in the file replaces the same-titled section in place (case-insensitive), the ````gstack-ship-receipt```` block is a section of its own so a receipt rewrite replaces only the receipt, sections the file does not rewrite are kept byte for byte, new sections are appended after them, and a current body with no `## ` headings is kept as the leading text. A `## ` line inside a code fence is not a section: a fence closes only on a run of its own character at least as long as the opener with nothing after it, so a ``` line inside a ```` block or a ```js line closes nothing, and a receipt fence that never closes is an unclosed fence (the body after it stays one kept section) rather than a receipt that swallows `## Approvals`. The file's leading text replaces the current leading text; text after a receipt block never replaces an intro; whitespace-only text in the file is dropped, so reposting the body /ship composes (receipt, blank line, sections) is byte-stable. One stderr line names the replaced, kept and added sections. `--replace` keeps the plain overwrite for a caller that composed the whole body. The redaction scan and the confirmation token cover the merged bytes, which are the bytes sent. A current body that cannot be read posts nothing (exit 3) and names `--replace`. +- **`lib/pregate/index.ts`**, **`lib/ship-queue.ts`**: `gstack-pregate` mirrors every record it writes to `/pregate/by-tree/.json` (best effort; a mirror failure never fails the record). `ready` reads the checkout's `.gstack/tmp/pregate.json` (or `--pregate`) first; when it is missing, unreadable, recorded for another tree, or current but still waiting on a remote receipt, it looks the current tree up in the mirror and uses that record when it verifies, printing `PREGATE: … (from the state root's pregate/by-tree/.json, recorded by another checkout)`. A lane whose `gstack-pregate --remote` had to run in a separate clone (a worktree cannot host it) no longer gets `PREGATE_STALE`, `PREGATE_REMOTE_UNCLEARED` or `missing` from its own checkout. Only a complete run stands in: a mirror that misses a registered check (an `--only` or single-stage run), was recorded against another base, or ran under `--policy-from head` is named on the `PREGATE:` line and never certifies READY; a record whose own checks failed or stayed incomplete is never overridden. A mirror for another tree changes nothing; the mirror is written through a temp file and rename. ### Docs - `docs/releases.md`: the release-gate paragraph describes both kinds of evidence, what train evidence does not relax, the `CANDIDATE` wording and the three flags. `docs/pregate.md`: the by-tree mirror and how `ready` reads it. diff --git a/docs/pregate.md b/docs/pregate.md index ad673927e..3af4d2f24 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -278,14 +278,20 @@ runs the same check standalone. byte, to `/pregate/by-tree/.json` (the state root from docs/state-root.md). `gstack-ship-queue ready` reads the checkout's `.gstack/tmp/pregate.json` first; when that file is missing or was recorded -for another tree, it looks the current tree up in the mirror and uses that -record when it verifies, printing `PREGATE: … (from , recorded by -another checkout)`. This is for the lane whose `gstack-pregate --remote` had -to run from a separate clone (a worktree cannot host it): the clone's record -clears the lane's own checkout as long as both hold the same tree. An explicit -`--pregate ` that verifies is used as before; a mirror for a different -tree changes nothing. Writing the mirror is best effort and never fails the -record itself. +for another tree, or is current but still waits on a remote receipt, it looks +the current tree up in the mirror and uses that record when it verifies, +printing `PREGATE: … (from the state root's pregate/by-tree/.json, +recorded by another checkout)`. This is for the lane whose `gstack-pregate +--remote` had to run from a separate clone (a worktree cannot host it): the +clone's record clears the lane's own checkout as long as both hold the same +tree. Only a complete run stands in: the mirror must name every registered +check (so a `--only` or single-stage run is "a partial run without …"), be +recorded against `origin/` and not under `--policy-from head`; +otherwise the `PREGATE:` line says why the mirror cannot stand in. A record +whose own checks failed or stayed incomplete is final and no mirror overrides +it. An explicit `--pregate ` that verifies is used as before; a mirror +for a different tree changes nothing. Writing the mirror is best effort (a +temp file renamed into place) and never fails the record itself. Unknown flags exit 2 with usage on every verb (an agent told to run a flag this gstack lacks never gets a green it did not ask for). Tier 2 checks still to come diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d06037017..5648d3ba4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2305,9 +2305,9 @@ exact tokens it finds in these positions: - an 11-digit id right after a run word on the same line, with an optional `:`, `#` or `=` between: `run`, `runs`, `screen`, `screens`, `gate`, `gates`, `backstop`, `bisect`, `rerun`, `dispatched`, `dispatch`, - `failed`, `passed`, `workflow`, `job`, `attempt`, `id`, `ids` - (`failed 40269992077 | rerun passed 40287239206`, `gate 40323989742`, - `run #19368712345`); + `failed`, `passed` (`failed 40269992077 | rerun passed 40287239206`, + `gate 40323989742`, `run #19368712345`); a generic word such as `id`, + `job`, `attempt` or `workflow` exempts nothing; - every 11-digit id chained after an allowlisted one through `, `, ` and `, ` & `, ` or ` or ` / `, optionally with a `(#1234)` between (`screen 40269992077 (#3434) and 40275443393 (#3453)`). diff --git a/lib/gstack-post.ts b/lib/gstack-post.ts index 06dede9af..7e3829c84 100644 --- a/lib/gstack-post.ts +++ b/lib/gstack-post.ts @@ -142,25 +142,32 @@ interface Section { key: string; label: string; text: string } * ````gstack-ship-receipt```` block is its own section (lib/ship-receipt.ts * extractReceipt reads it); text before the first heading is the leading * text, and text after a receipt block before the next heading is kept with - * a key of its own, so it is never replaced. + * a key of its own, so it is never replaced. A fence closes only on a run of + * its own character at least as long as the opener with nothing after it + * (CommonMark), so a ``` line inside a ```` block and a ```js line close + * nothing; a receipt fence that never closes is an ordinary unclosed fence, + * so the rest of the body stays one kept section instead of a receipt. */ -function splitSections(body: string): Section[] { +function splitSections(body: string, receipts = true): Section[] { const out: Section[] = []; let fence: string | null = null; let plain = 0; const open = (key: string, label: string) => out.push({ key, label, text: "" }); - const openText = () => open(`text:${plain}`, plain++ === 0 ? "leading text" : "trailing text"); + const openText = () => out.length === 0 ? open("text:lead", "leading text") : open(`text:${plain++}`, "trailing text"); + const closes = (run: string) => new RegExp(`^ {0,3}${run[0] === "`" ? "`" : "~"}{${run.length},}\\s*$`).test(bare); + let bare = ""; for (const line of body.split(/(?<=\n)/)) { - const bare = line.replace(/\r?\n$/, ""); - const fenceOpen: RegExpExecArray | null = fence === null ? /^ {0,3}(```|~~~)/.exec(bare) : null; - if (fenceOpen && bare.trim() === "```" + RECEIPT_FENCE) { open("receipt", "ship receipt"); fence = "receipt"; } - else if (fence === "receipt") { out.at(-1)!.text += line; if (bare.trim() === "```") { fence = null; openText(); } continue; } + bare = line.replace(/\r?\n$/, ""); + const fenceOpen: RegExpExecArray | null = fence === null ? /^ {0,3}(`{3,}|~{3,})/.exec(bare) : null; + if (fenceOpen && receipts && bare.trim() === "```" + RECEIPT_FENCE) { open("receipt", "ship receipt"); fence = "receipt"; } + else if (fence === "receipt") { out.at(-1)!.text += line; if (closes("```")) { fence = null; openText(); } continue; } else if (fenceOpen) fence = fenceOpen[1]!; - else if (fence && bare.trim().startsWith(fence)) fence = null; + else if (fence && closes(fence)) fence = null; else if (fence === null && /^ {0,3}## /.test(bare)) { const title = bare.replace(/^ {0,3}## /, "").trim(); open(`h2:${title.toLowerCase()}`, title); } if (out.length === 0) openText(); out.at(-1)!.text += line; } + if (fence === "receipt") return splitSections(body, false); return out.filter(s => s.text !== ""); } @@ -169,17 +176,21 @@ function splitSections(body: string): Section[] { * file replaces the same-keyed section of the current body in place, current * sections the file does not rewrite are kept byte for byte, new `## ` * sections are appended after them, and a new receipt or leading text goes - * first (where lib/ship-receipt.ts puts the receipt). A blank line separates - * the two sides of every seam the merge creates; two kept neighbours keep - * their bytes. An empty current body yields the file unchanged. + * first (where lib/ship-receipt.ts puts the receipt). Text outside sections + * matches by role (the file's leading text replaces the leading text; text + * after a receipt block never replaces an intro), and whitespace-only text in + * the file is dropped, so reposting the body /ship composes (receipt, blank + * line, sections) yields the same bytes. A blank + * line separates the two sides of every seam the merge creates; two kept + * neighbours keep their bytes. An empty current body yields the file unchanged. */ export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[] } { const now = splitSections(current); - const next = splitSections(incoming); + const next = splitSections(incoming).filter(n => !(n.key.startsWith("text:") && n.text.trim() === "")); const used = new Set
(); const replaced: string[] = [], kept: string[] = [], added: string[] = []; const merged: Section[] = now.map(s => { - const match = next.find(n => !used.has(n) && n.key === s.key && !n.key.startsWith("text:")); + const match = next.find(n => !used.has(n) && n.key === s.key); if (!match) { if (s.text.trim()) kept.push(s.label); return s; } used.add(match); replaced.push(s.label); return match; }); @@ -317,8 +328,8 @@ function repoReleaseVersions(env: PostEnv): string[] { return [...versions]; } -/** A run word with an optional ':' '#' '=' right before an 11-digit token: `run 19368712345`, `gate=40323989742`, `job: 40323989748`. */ -const RUN_WORD_BEFORE = /\b(?:runs?|screens?|gates?|backstop|bisect|rerun|dispatch(?:ed)?|failed|passed|workflow|job|attempt|ids?)\s*[:#=]?\s*$/i; +/** A run word with an optional ':' '#' '=' right before an 11-digit token: `run 19368712345`, `gate=40323989742`, `rerun passed 40287239206`. */ +const RUN_WORD_BEFORE = /\b(?:runs?|screens?|gates?|backstop|bisect|rerun|dispatch(?:ed)?|failed|passed)\s*[:#=]?\s*$/i; /** Between two 11-digit tokens of one list: `, `, ` and `, ` & `, ` or `, ` / `, with an optional `(#1234)` after the first. */ const RUN_ID_CHAIN = /^(?:\s*\(#\d+\))?\s*(?:,|and|&|or|\/)\s*$/i; @@ -330,9 +341,10 @@ const RUN_ID_CHAIN = /^(?:\s*\(#\d+\))?\s*(?:,|and|&|or|\/)\s*$/i; * run id inside an /actions/runs/ URL (the engine already exempts the URL * itself; this covers its link text), and an 11-digit run id right after a run * word on the same line (run, runs, screen, screens, gate, gates, backstop, - * bisect, rerun, dispatched, dispatch, failed, passed, workflow, job, attempt, - * id, ids, with an optional ':' '#' '=' between: `failed 40269992077`, - * `gate 40323989742`, `run #19368712345`), plus every 11-digit id chained + * bisect, rerun, dispatched, dispatch, failed, passed, with an optional ':' + * '#' '=' between: `failed 40269992077`, `gate 40323989742`, + * `run #19368712345`; generic words such as id, job, attempt and workflow + * exempt nothing, since `customer id 14155551234` is a number), plus every 11-digit id chained * after an allowlisted one through ", ", " and ", " & ", " or " or " / ", * optionally with a "(#1234)" between (`screen 40269992077 (#3434) and * 40275443393 (#3453)`). Only the exact tokens found in those positions are diff --git a/lib/issue-links.ts b/lib/issue-links.ts index eb894a552..fcc7e3572 100644 --- a/lib/issue-links.ts +++ b/lib/issue-links.ts @@ -10,8 +10,9 @@ * deferred ask (`defer`, `follow-up`) and every cross-repo id * (`owner/repo#N`) are `Refs #N` — never auto-closed. * - A parenthesised named reference, `(name #N, #M)`, `(name PR #N)` or - * `(name issue #N)` where name is one token that is not a closure or - * reference word, is a cross-repo reference to `name`: rendered as + * `(name issue #N)` where name is one token that is not a closure, + * reference, filler or version word (`closing`, `cf.`, `e.g.`, `v1.2` + * name nothing), is a cross-repo reference to `name`: rendered as * `Refs name#N`, never a same-repo Refs line or a promotion candidate. * - A closure issue that cannot be read through gstack-issue-guard is * downgraded to `Refs #N` with ISSUE_UNREADABLE (never Fixes). @@ -47,7 +48,7 @@ const BARE = /(?/pregate/by-tree/ (gstack-pregate mirrors every - * record there), and the PREGATE line says where it came from. + * record there), and the PREGATE line says where it came from; + * a record whose own checks failed or stayed incomplete stands. * stamp preflight (gated head equals HEAD modulo stamps, base, policy, * gate record under full_e2e), restamp, commit, tree receipt, * each stage journaled; QUEUE_STAMP: head= gated= @@ -25,7 +27,8 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index'; +import type { PregateFile } from './headless-artifacts'; +import { CHECK_IDS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index'; import { ensureTmpExcluded, restamp, versionAtRef } from './restamp'; import type { ResultCodeName } from './result-codes'; import { bindReceipt, extractReceipt } from './ship-receipt'; @@ -183,6 +186,20 @@ function gateEvidence(ctx: QueueContext, pr: string, head: string, attempt: Atte return { ok: false, line: `gate-evidence: missing for ${head.slice(0, 12)} (gate-ahead --record, or --gate-exit 0 --gate-log )` }; } +/** + * Why a by-tree mirror cannot stand in for this checkout's own record, or + * null. The mirror is keyed by tree alone, so any pregate run anywhere mints + * one; only a complete run (every registered check, so no `--only` or single + * stage) against the same base under a reviewed policy (not `--policy-from + * head`) is publication evidence for this PR. + */ +function mirrorGap(file: PregateFile, base: string): string | null { + if (!(file.base ?? '').startsWith(`origin/${base}@`)) return `recorded against ${file.base ?? 'no base'}, not origin/${base}`; + if (file.policy?.startsWith('head ')) return 'recorded under the unreviewed head policy'; + const missing = CHECK_IDS.filter(id => !file.checks.some(c => c.id === id)); + return missing.length ? `a partial run without ${missing.join(', ')}` : null; +} + function gateLine(policy: LoadedPolicy, attempt: AttemptRecord | null): string { const p = policy.policy; const rec = attempt ? `attempt=${attempt.attempt} gated_tree=${attempt.gated_tree.slice(0, 12)} ${attempt.result ? `exit=${attempt.result.exit}` : 'not recorded'}` : 'none'; @@ -211,24 +228,27 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`; let pregateOk = false; const treeNow = wtreeFingerprint({ cwd: ctx.repoRoot }); - let otherTree = true; + // The mirror stands in for a record that is missing, unreadable, recorded for another tree, or current but + // waiting on a remote receipt another checkout holds; a record whose own checks failed or stayed incomplete is final. + let useMirror = true; if (fs.existsSync(pregateFile)) { const read = readPregateFile(pregateFile); - otherTree = read.file !== null && 'tree' in treeNow && read.file.tree !== treeNow.tree; if (read.file) { const v = verifyPregate(ctx.repoRoot, read.file, treeNow); pregateOk = v.ok; + useMirror = !v.ok && v.code !== 'PREGATE_LANE_FAILED' && v.code !== 'PREGATE_INCOMPLETE'; pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`; } else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`; } - if (!pregateOk && otherTree && 'tree' in treeNow) { + if (useMirror && 'tree' in treeNow) { const mirror = pregateMirrorPath(treeNow.tree); const read = fs.existsSync(mirror) ? readPregateFile(mirror) : null; - const v = read?.file ? verifyPregate(ctx.repoRoot, read.file, treeNow) : null; + const gap = read?.file ? mirrorGap(read.file, ctx.base) : null; + const v = read?.file && !gap ? verifyPregate(ctx.repoRoot, read.file, treeNow) : null; if (read?.file && v?.ok) { pregateOk = true; - pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from ${mirror}, recorded by another checkout)`; - } + pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`; + } else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`; } const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? '')); const attempt = currentAttempt(ctx.repoRoot, o.pr); diff --git a/scripts/release-cut.ts b/scripts/release-cut.ts index 688b5446e..055dec0a1 100755 --- a/scripts/release-cut.ts +++ b/scripts/release-cut.ts @@ -97,7 +97,7 @@ export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions = const named = runs.filter(c => c.name === name); if (named.some(c => c.status !== 'completed')) return { green: false, why: `${name} is still running`, pending: true }; if (named.some(c => c.conclusion === 'success')) continue; - const train = name === backstop && o.evidence ? o.evidence.bySha.get(sha) ?? o.evidence.byTree.get(o.treeOf?.(sha) ?? '') : undefined; + const train = name === backstop && name !== required && o.evidence ? o.evidence.bySha.get(sha) ?? o.evidence.byTree.get(o.treeOf?.(sha) ?? '') : undefined; if (train) { via = `train ${train}`; continue; } if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false }; return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false }; @@ -131,13 +131,16 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes { for (const line of listed.out.split('\n')) { const blob = line.split(' ')[0]; if (!blob) continue; - let r: NoteRecord; - try { r = JSON.parse(git(['cat-file', 'blob', blob]).out) as NoteRecord; } catch { continue; } + let parsed: unknown; + try { parsed = JSON.parse(git(['cat-file', 'blob', blob]).out); } catch { continue; } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + const r = parsed as NoteRecord; if (r.kind === 'freeze' && !r.cleared_at) notes.freeze = `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`; - if (r.kind !== 'train' || !r.nonce || !['green', 'merging', 'merged'].includes(r.state ?? '')) continue; - for (const m of r.merged ?? []) { - if (m.sha) notes.evidence.bySha.set(m.sha, r.nonce); - if (m.tree) notes.evidence.byTree.set(m.tree, r.nonce); + if (r.kind !== 'train' || typeof r.nonce !== 'string' || !r.nonce || !['green', 'merging', 'merged'].includes(r.state ?? '')) continue; + for (const m of Array.isArray(r.merged) ? r.merged : []) { + if (!m || typeof m !== 'object') continue; + if (typeof m.sha === 'string') notes.evidence.bySha.set(m.sha, r.nonce); + if (typeof m.tree === 'string') notes.evidence.byTree.set(m.tree, r.nonce); } } return notes; diff --git a/test/gstack-post.test.ts b/test/gstack-post.test.ts index 36e6ae8e5..9ad6c8506 100644 --- a/test/gstack-post.test.ts +++ b/test/gstack-post.test.ts @@ -7,7 +7,7 @@ * the real executable against stub gh/glab on POSIX. */ import { describe, expect, test } from "bun:test"; -import { EXIT, runPost, type PostEnv } from "../lib/gstack-post"; +import { EXIT, mergeBodySections, runPost, type PostEnv } from "../lib/gstack-post"; interface Call { cmd: string; args: string[]; input?: string } @@ -227,9 +227,10 @@ describe("gstack-post owns the redaction scan", () => { }); // Value: protects=an 11-digit GitHub Actions run id written after a run word (run, screen, gate, backstop, bisect, rerun, dispatched, failed, passed, - // workflow, job, attempt, id, with an optional ':' '#' '='), or chained after one with ",", "and", "&", "or" or "/" (a "(#1234)" between allowed), posts - // without a pii.phone.e164 confirmation; fails_when=real receipts ("failed 40269992077 | rerun passed 40287239206", "screen 40269992077 (#3434) and - // 40275443393 (#3453)", "gate 40323989742") need --confirm, or a bare 11-digit number, a 10-digit national number or an E.164 number stops reporting; + // with an optional ':' '#' '='), or chained after one with ",", "and", "&", "or" or "/" (a "(#1234)" between allowed), posts without a pii.phone.e164 + // confirmation, while a generic word (id, job, attempt, workflow) exempts nothing; fails_when=real receipts ("failed 40269992077 | rerun passed + // 40287239206", "screen 40269992077 (#3434) and 40275443393 (#3453)", "gate 40323989742") need --confirm, "customer id 14155551234" or "job 40323989748" + // posts unconfirmed, or a bare 11-digit number, a 10-digit national number or an E.164 number stops reporting; // why_new=the receipt-token test allowlists only "run " and the /actions/runs/ URL; seam=none; no_claim=none beyond protects test("run ids next to run words, and the ids chained after them, are not phone numbers; a bare id, a national number and an E.164 number still are", () => { const clean = [ @@ -239,7 +240,6 @@ describe("gstack-post owns the redaction scan", () => { "gates: 40323989742, 40323989743 or 40323989744", "backstop=40323989742 bisect #40323989745", "dispatched 40323989742 / 40323989746 & 40323989747", - "workflow 40323989742; job: 40323989748; attempt 40323989749; id 40323989750; ids 40323989751, 40323989752", "runs 40323989742 and 40323989753 (#3454), 40323989754", ]; for (const body of clean) { @@ -249,6 +249,9 @@ describe("gstack-post owns the redaction scan", () => { } const controls = [ "the id is 19368712345", + "customer id 14155551234", + "job 40323989748", + "workflow 40323989742; attempt 40323989749; ids 40323989751, 40323989752", "40269992077 failed", "gate 40323989742. Then 40323989743 on its own", "gate 4155551234", @@ -338,6 +341,29 @@ describe("gstack-post pr-body merges the PR body by section", () => { expect(sent(fenced).input).toBe("## Notes\n\n```md\n## not a heading\n```\n\n## Why\n\nnew\n"); }); + // Value: protects=a fence closes only on a run of its own character at least as long as the opener with nothing after it, so a ``` line inside a + // ```` block, a ```js line and a ~~~ line close nothing and the headings inside stay kept bytes, and a receipt fence that never closes is an unclosed + // fence rather than a receipt that swallows the sections after it; fails_when=a longer or info-string fence closes early and a heading inside the + // block is replaced (dropping the block's own closing fence and the sections after it), or an unclosed receipt fence loses ## Approvals; + // why_new=the fenced case above covers only a three-backtick block closed by its own fence; seam=fake run; no_claim=real gh + test("longer fences, info-string closers and an unclosed receipt fence never expose a heading inside them to the merge", () => { + const long = "## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nold\n"; + const four = withBody(long, { "/b": "## Tests\n\nnew\n" }); + expect(run(["pr-body", "5", "--body-file", "/b"], four)).toBe(EXIT.posted); + expect(sent(four).input).toBe("## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nnew\n"); + expect(four.stderr()).toBe("gstack-post: pr-body merged by section: replaced Tests; kept Notes; added none\n"); + + const info = withBody("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nold\n", { "/b": "## Tests\n\nnew\n" }); + expect(run(["pr-body", "5", "--body-file", "/b"], info)).toBe(EXIT.posted); + expect(sent(info).input).toBe("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nnew\n"); + + const unclosed = "```gstack-ship-receipt\nSHIP_RECEIPT: pr=5 head=aaaa\n\n## Summary\n\nold\n\n## Approvals\n\n- owner: do it\n"; + const open = withBody(unclosed, { "/b": RECEIPT("bbbb") + "\n## Summary\n\nnew\n" }); + expect(run(["pr-body", "5", "--body-file", "/b"], open)).toBe(EXIT.posted); + expect(sent(open).input).toBe(RECEIPT("bbbb") + "\n" + unclosed + "\n## Summary\n\nnew\n"); + expect(open.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text; added ship receipt, Summary\n"); + }); + // Value: protects=--replace keeps the plain overwrite for a caller that composed the whole body, a current body with no H2 headings is kept as the leading // section, an empty current body posts the new body byte for byte, the scan runs on the merged bytes, and an unreadable current body is exit 3 with // the --replace hint rather than an overwrite; fails_when=--replace merges, headingless text is dropped, the merge changes a lone new body, a secret in a @@ -370,4 +396,96 @@ describe("gstack-post pr-body merges the PR body by section", () => { expect(unreadable.posts()).toEqual([]); expect(run(["pr-comment", "5", "--replace", "--body-file", "/b"], fakeEnv({ files: { "/b": "x" } }))).toBe(EXIT.usage); }); + + // Value: protects=on GitLab the merge reads the MR's description from `glab mr view --output json`, posts the merged bytes through + // `glab mr update --description=`, treats an empty answer as an empty body (the file is posted unchanged), and a non-JSON answer is exit 3 with the + // --replace hint and nothing posted; fails_when=the GitLab merge reads the wrong field or command, overwrites the MR on a failed read, or refuses an MR + // whose description is empty; why_new=the merge tests above run against gh only; seam=fake run answers glab mr view; no_claim=real glab + test("on GitLab the current description comes from glab mr view JSON; an empty answer is an empty body; a non-JSON answer refuses", () => { + const remote = "https://gitlab.com/grp/sub/proj.git"; + const view = (stdout: string) => (c: Call) => (c.cmd === "glab" && c.args[0] === "mr" && c.args[1] === "view" ? { status: 0, stdout } : undefined); + const merged = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view(JSON.stringify({ iid: 8, description: "## What changed\n\n- old\n\n## Why\n\nold\n" }) + "\n") }); + expect(run(["pr-body", "8", "--body-file", "/b"], merged)).toBe(EXIT.posted); + expect(merged.calls.find(c => c.cmd === "glab" && c.args[1] === "view")!.args).toEqual(["mr", "view", "8", "--output", "json"]); + expect(merged.posts()).toEqual([{ cmd: "glab", args: ["mr", "update", "8", "--description=## What changed\n\n- old\n\n## Why\n\nnew\n"], input: undefined }]); + expect(merged.stderr()).toBe("gstack-post: pr-body merged by section: replaced Why; kept What changed; added none\n"); + + const empty = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("") }); + expect(run(["pr-body", "8", "--body-file", "/b"], empty)).toBe(EXIT.posted); + expect(empty.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]); + const nullDescription = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view('{"iid":8,"description":null}\n') }); + expect(run(["pr-body", "8", "--body-file", "/b"], nullDescription)).toBe(EXIT.posted); + expect(nullDescription.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]); + + const garbled = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("502\n") }); + expect(run(["pr-body", "8", "--body-file", "/b"], garbled)).toBe(EXIT.cliFailed); + expect(garbled.stderr()).toContain("cannot read PR 8's current body to merge into (glab returned no JSON)"); + expect(garbled.stderr()).toContain("--replace"); + expect(garbled.posts()).toEqual([]); + }); + + // Value: protects=section matching survives the bodies GitHub actually returns and the shapes /ship composes: a CRLF current body (web-edited) keeps + // its kept section's bytes and still replaces the same-titled section, a receipt new to a body without one goes first (where gstack-ship-receipt puts + // it), a current body with two same-titled sections replaces the first and keeps the second, and a current body with no trailing newline still gets a + // blank-line seam before an appended section; fails_when=a CRLF title no longer matches an LF one (the section is appended as a duplicate), a kept + // CRLF section is rewritten, a new receipt lands after the sections, the second duplicate is dropped, or an appended section is glued to the last line; + // why_new=the merge tests above use LF bodies with unique titles and a receipt that already exists; seam=none (mergeBodySections); no_claim=rendering + test("a CRLF current body, a receipt new to the body, duplicate titles and a body without a trailing newline merge by section", () => { + const crlfKept = "## What changed\r\n\r\n- old\r\n\r\n"; + const crlf = mergeBodySections(crlfKept + "## Why\r\n\r\nBecause.\r\n", "## why\n\nnew\n"); + expect([crlf.replaced, crlf.kept, crlf.added]).toEqual([["Why"], ["What changed"], []]); + expect(crlf.body.startsWith(crlfKept)).toBe(true); + expect(crlf.body.endsWith("\n## why\n\nnew\n")).toBe(true); + expect(crlf.body).not.toContain("Because."); + expect(crlf.body.match(/^ {0,3}## /gm)).toHaveLength(2); + + const receipt = "```gstack-ship-receipt\n{\n \"pr\": \"5\"\n}\n```\n"; + const fresh = mergeBodySections("## A\n\na\n", receipt + "## A\n\nb\n"); + expect(fresh.body).toBe(receipt + "\n## A\n\nb\n"); + expect([fresh.replaced, fresh.kept, fresh.added]).toEqual([["A"], [], ["ship receipt"]]); + + const twice = mergeBodySections("## A\na\n## A\na2\n", "## A\nnew\n"); + expect(twice.body).toBe("## A\nnew\n\n## A\na2\n"); + expect([twice.replaced, twice.kept]).toEqual([["A"], ["A"]]); + + const glued = mergeBodySections("## A\n\na", "## B\n\nb"); + expect(glued.body).toBe("## A\n\na\n\n## B\n\nb"); + expect(mergeBodySections("## A\n\na\n\n", "## B\n\nb\n").body).toBe("## A\n\na\n\n## B\n\nb\n"); + }); + + // Value: protects=the body /ship composes (lib/ship-receipt.ts placeReceiptFirst: the receipt block, one blank line, then the sections) reposts + // idempotently: merged into a PR body of the same shape it yields the file's bytes, the receipt stays byte-first, and the stderr line reports no + // addition; fails_when=the blank line after the receipt is read as new leading text and prepended before the receipt (one more leading newline per + // repost, "added leading text" on every /ship re-READY), or a kept blank line is counted as added; why_new=the merge tests above post a receipt-only + // file or a file without a receipt, never the composed shape; seam=none (mergeBodySections); no_claim=the gh round trip + test("the composed receipt-then-sections body reposts byte for byte, receipt first, with nothing reported as added", () => { + const receipt = (head: string) => "```gstack-ship-receipt\n{\n \"head\": \"" + head + "\"\n}\n```\n"; + const composed = (head: string, summary: string) => receipt(head) + "\n## Summary\n\n" + summary + "\n\n## Test plan\n\n- [x] bun test\n"; + const first = mergeBodySections(composed("aaaa", "old"), composed("bbbb", "new")); + expect(first.body).toBe(composed("bbbb", "new")); + expect([first.replaced, first.kept, first.added]).toEqual([["ship receipt", "Summary", "Test plan"], [], []]); + const again = mergeBodySections(first.body, composed("bbbb", "new")); + expect(again.body).toBe(composed("bbbb", "new")); + expect(again.added).toEqual([]); + }); + + // Value: protects=leading prose in the file replaces the current body's leading prose instead of being prepended again, so a repost of a file that + // opens with text keeps one copy; fails_when=text sections are never matched and the file's leading text is treated as fresh on every post; + // why_new=the merge tests above post files that open with a heading or a receipt, never with prose; seam=none (mergeBodySections); no_claim=the gh round trip + test("leading prose in the file replaces the current leading prose and reposts once", () => { + const current = "Intro line\n\n## Summary\n\nold\n\n## Approvals\n\nx\n"; + const once = mergeBodySections(current, "Intro line\n\n## Summary\n\nnew\n"); + expect(once.body).toBe("Intro line\n\n## Summary\n\nnew\n\n## Approvals\n\nx\n"); + expect([once.replaced, once.kept, once.added]).toEqual([["leading text", "Summary"], ["Approvals"], []]); + const twice = mergeBodySections(once.body, "Intro line\n\n## Summary\n\nnew\n"); + expect(twice.body).toBe(once.body); + const fresh = mergeBodySections("## Summary\n\nold\n", "Intro line\n\n## Summary\n\nnew\n"); + expect(fresh.body).toBe("Intro line\n\n## Summary\n\nnew\n"); + expect(fresh.added).toEqual(["leading text"]); + const receipt = "```gstack-ship-receipt\n{\n \"head\": \"a\"\n}\n```\n"; + const afterReceipt = mergeBodySections("Intro line\n\n## Summary\n\nold\n", receipt + "note\n\n## Summary\n\nnew\n"); + expect(afterReceipt.body).toContain("Intro line\n"); + expect(afterReceipt.body).toContain("note\n"); + expect(afterReceipt.replaced).toEqual(["Summary"]); + }); }); diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 2ae4cadf4..bd7120e7b 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -293,29 +293,63 @@ describe('ready', () => { expect(stalePregate.status).toBe(EXIT.refused); expect(stalePregate.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m); // A lane that cleared its pregate from a separate clone (gstack-pregate --remote cannot run in a worktree) holds the verdict under - // /pregate/by-tree/.json; ready reads that mirror for the current tree when its own record is stale or missing. + // /pregate/by-tree/.json; ready reads that mirror for the current tree when its own record is stale or missing, and only a + // complete run (every registered check) against origin/main under a reviewed policy stands in: a --only run, another base or a head policy is named. const stateRoot = path.join(repo.root, 'state-root'); + const CHECK_IDS = ['regen', 'secrets', 'strays', 'literals', 'timing', 'guards', 'lanes']; + const complete = { base: `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}`, policy: 'origin/main@abc (no .gstack/pregate.json)', checks: CHECK_IDS.map(id => ({ id, status: 'pass' })) }; + const fromSuffix = `(from the state root's pregate/by-tree/${'TREE'}.json, recorded by another checkout)`; const movedShim = ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } }); const movedTree = runBin(repo, 'gstack-wtree', []).out.trim(); const mirror = path.join(stateRoot, 'pregate', 'by-tree', `${movedTree}.json`); fs.mkdirSync(path.dirname(mirror), { recursive: true }); - fs.writeFileSync(path.join(stateRoot, 'pregate', 'by-tree', `${'0'.repeat(movedTree.length)}.json`), JSON.stringify({ schema_version: 1, tree: '0'.repeat(movedTree.length), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] })); + fs.writeFileSync(path.join(stateRoot, 'pregate', 'by-tree', `${'0'.repeat(movedTree.length)}.json`), JSON.stringify({ schema_version: 1, tree: '0'.repeat(movedTree.length), head: moved, verdict: 'pass', ...complete })); const otherTree = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); expect(otherTree.status).toBe(EXIT.refused); expect(otherTree.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m); const missing = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); expect(missing.status).toBe(EXIT.refused); expect(missing.out).toMatch(/^PREGATE: missing \(\.gstack\/tmp\/pregate\.json; run gstack-pregate run --allow-repo-commands\)$/m); - fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }, { id: 'lanes', status: 'pass' }] })); + const suffix = fromSuffix.replace('TREE', movedTree.slice(0, 12)); + const partial = { ...complete, checks: [{ id: 'timing', status: 'pass' }] }; + for (const [bad, why] of [[partial, 'a partial run without regen, secrets, strays, literals, guards, lanes'], [{ ...complete, base: 'origin/develop@0123456789ab' }, 'recorded against origin/develop@0123456789ab, not origin/main'], [{ ...complete, policy: 'head (unreviewed) .gstack/pregate.json' }, 'recorded under the unreviewed head policy']] as const) { + fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...bad })); + const ignored = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); + expect(ignored.status).toBe(EXIT.refused); + expect(line(ignored.out, 'PREGATE:')).toBe(`PREGATE: missing (.gstack/tmp/pregate.json; run gstack-pregate run --allow-repo-commands); the state root's mirror for this tree cannot stand in: ${why}`); + } + fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...complete })); const fromMirror = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); expect([fromMirror.status, fromMirror.err]).toEqual([EXIT.ok, '']); - expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=2 (from ${mirror}, recorded by another checkout)`); + expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=7 ${suffix}`); const missingFromMirror = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); expect([missingFromMirror.status, missingFromMirror.err]).toEqual([EXIT.ok, '']); - expect(line(missingFromMirror.out, 'PREGATE:')).toContain(`(from ${mirror}, recorded by another checkout)`); + expect(line(missingFromMirror.out, 'PREGATE:')).toContain(suffix); const current = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, moved)], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); expect([current.status, current.err]).toEqual([EXIT.ok, '']); expect(line(current.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1`); + // Value: protects=a same-tree record that only waits on a remote receipt (the small-machine run) is cleared by the clone's mirror, an + // unreadable record falls through to the mirror, and a record whose own checks failed or stayed incomplete is never overridden by a green mirror; + // fails_when=ready reads the mirror only for another tree (the documented lane case refuses PREGATE_REMOTE_UNCLEARED) or lets a mirror hide a red + // or incomplete local run; why_new=the rows above cover only stale, missing and current records; seam=none; no_claim=the mirror's writer + const local = path.join(repo.root, 'pregate-local.json'); + const obligation = { lane: 'pregate.json/remote_gate', platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: null }; + fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'requires-remote', checks: [{ id: 'lanes', status: 'requires-remote' }], requires_remote: [obligation] })); + const uncleared = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); + expect([uncleared.status, uncleared.err]).toEqual([EXIT.ok, '']); + expect(line(uncleared.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=7 ${suffix}`); + fs.writeFileSync(local, '{ not json'); + const unreadable = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); + expect([unreadable.status, unreadable.err]).toEqual([EXIT.ok, '']); + expect(line(unreadable.out, 'PREGATE:')).toContain(suffix); + fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'fail', checks: [{ id: 'lanes', status: 'fail' }] })); + const redLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); + expect(redLocal.status).toBe(EXIT.refused); + expect(line(redLocal.out, 'PREGATE:')).toBe(`PREGATE: fail tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 — 1 check(s) failed: lanes (PREGATE_LANE_FAILED)`); + fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'incomplete', checks: [{ id: 'lanes', status: 'incomplete' }] })); + const incompleteLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot }); + expect(incompleteLocal.status).toBe(EXIT.refused); + expect(line(incompleteLocal.out, 'PREGATE:')).toMatch(/\(PREGATE_INCOMPLETE\)$/); fs.appendFileSync(path.join(repo.work, 'pr-a.txt'), 'dirty\n'); expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/); }, 180_000); diff --git a/test/pregate-invalidation.test.ts b/test/pregate-invalidation.test.ts index 731d18941..3ee844898 100644 --- a/test/pregate-invalidation.test.ts +++ b/test/pregate-invalidation.test.ts @@ -90,4 +90,35 @@ describe('pregate invalidation', () => { expect(bad.status).toBe(EXIT.fail); expect(bad.err).toContain('(ARTIFACT_SCHEMA)'); }); + + // Value: protects=every record gstack-pregate writes (run and clear) is mirrored byte for byte to /pregate/by-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 /pregate/by-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); }); diff --git a/test/release-cut.test.ts b/test/release-cut.test.ts index 1603b9ec8..2d0a199d1 100644 --- a/test/release-cut.test.ts +++ b/test/release-cut.test.ts @@ -14,7 +14,7 @@ import * as path from 'node:path'; import { bunIsolationArgs } from '../lib/bun-isolation'; import { BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, gitIn, - planRelease, releaseNotes, type Gh, + planRelease, readTrainNotes, releaseNotes, type Gh, type Git, } from '../scripts/release-cut'; const bases: string[] = []; @@ -175,6 +175,48 @@ describe('release-cut: planning', () => { git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF); expect(planRelease({ repo: f.repo, git: f.git, gh: f.gh }).ok).toBe(true); }); + + // Value: protects=one read of the trains notes ref feeds both gates: a fetch failure other than a missing ref refuses the plan with its message, a + // missing ref is no freeze and no evidence, a blob that is not JSON, is JSON that is not an object (null, an array, a string, a number), carries a + // malformed merged[] or a non-string nonce, or is a train without a nonce is skipped while a freeze and a train in the same + // ref are both read (the freeze still wins over the evidence); fails_when=a fetch error is read as "no notes" and a frozen or ungated main is + // released, an unparsable note aborts the read, or evidence from a later note is lost once a freeze is seen; why_new=the freeze test covers one + // readable freeze record only and the evidence tests never mix record kinds or break the fetch; seam=none; no_claim=a real origin outage + test('readTrainNotes: a fetch error refuses, a missing ref holds nothing, junk notes are skipped, and a freeze beside train evidence still refuses', () => { + const f = fixture(); + const head = f.shas['1.0.3']!; + const failing: Git = (args) => (args[0] === 'fetch' ? { ok: false, out: 'fatal: unable to access the remote: Could not resolve host' } : f.git(args)); + const broken = planRelease({ repo: f.repo, git: failing, gh: f.gh }); + expect(broken).toEqual({ ok: false, code: 'REFUSED', message: 'cannot fetch ' + NOTES_REF + ': fatal: unable to access the remote: Could not resolve host' }); + const absent = readTrainNotes(f.git); + expect([absent.error, absent.freeze, absent.evidence.bySha.size, absent.evidence.byTree.size]).toEqual([null, null, 0, 0]); + + const add = (record: string, on: string) => git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-f', '-m', record, on); + add('not json {', f.shas['1.0.0']!); + for (const junk of ['null', '[]', '"train"', '7', JSON.stringify({ kind: 'train', nonce: 'bad1', state: 'green', merged: 'x' }), JSON.stringify({ kind: 'train', nonce: 'bad2', state: 'green', merged: [null, 'y', { sha: 3, tree: ['t'] }] }), JSON.stringify({ kind: 'train', nonce: 9, state: 'green', merged: [{ sha: f.shas['1.0.0'] }] })]) { + add(junk, f.shas['1.0.0']!); + git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF); + const read = readTrainNotes(f.git); + expect([junk, read.error, read.freeze, read.evidence.bySha.size, read.evidence.byTree.size]).toEqual([junk, null, null, 0, 0]); + } + add(JSON.stringify({ schema: 1, kind: 'train', state: 'merged', merged: [{ sha: f.shas['1.0.1'], tree: 't1' }] }), f.shas['1.0.1']!); + add(JSON.stringify({ schema: 1, kind: 'train', nonce: 'ok1', state: 'green', merged: [{ pr: '2' }, { sha: f.shas['1.0.2'] }, { tree: 'tree-only' }] }), f.shas['1.0.2']!); + add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: null }), head); + git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF); + const mixed = readTrainNotes(f.git); + expect(mixed.error).toBeNull(); + expect(mixed.freeze).toBe('queue frozen since train ok1: backstop red'); + expect([...mixed.evidence.bySha]).toEqual([[f.shas['1.0.2']!, 'ok1']]); + expect([...mixed.evidence.byTree]).toEqual([['tree-only', 'ok1']]); + f.state.checks[f.shas['1.0.2']!] = backstop('completed', 'neutral'); + const frozen = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh }); + expect(frozen).toEqual({ ok: false, code: 'REFUSED', message: 'queue frozen since train ok1: backstop red' }); + add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: 'y' }), head); + git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF); + const thawed = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh }); + expect(thawed.ok, JSON.stringify(thawed)).toBe(true); + if (thawed.ok) expect(thawed.plan.via).toBe('train ok1'); + }); }); describe('release-cut: train evidence gates a commit whose backstop never settled', () => { @@ -217,10 +259,11 @@ describe('release-cut: train evidence gates a commit whose backstop never settle if (plain.ok) { expect(plain.plan.via).toBe(BACKSTOP_CHECK); expect(describePlan(plain.plan, false)[0]).toBe('CANDIDATE: ' + f.shas['1.0.2'] + ' (' + plain.plan.date + ') [hotfix: --ref] gated by ' + BACKSTOP_CHECK); } }); - // Value: protects=train evidence never relaxes the rest of the gate: a covered commit still needs a successful required check run, no red check run (a red - // backstop included), nothing still running, and only a green, merging or merged record counts; fails_when=a covered commit with a failed free-tests run, - // a red windows lane or a red backstop is released, a running check is not waited for, or a red or superseded train record is read as evidence; - // why_new=the first train test covers the accepting path only; seam=none; no_claim=none beyond protects + // Value: protects=train evidence never relaxes the rest of the gate: a covered commit still needs a successful required check run (also when + // --required-check and --backstop-check name the same check, where evidence substitutes for nothing), no red check run (a red backstop included), + // nothing still running, and only a green, merging or merged record counts; fails_when=a covered commit with a failed free-tests run, a red windows lane + // or a red backstop is released, a running check is not waited for, a red or superseded train record is read as evidence, or equal check names let a + // covered commit with zero check runs through; why_new=the first train test covers the accepting path only; seam=none; no_claim=none beyond protects test('a covered commit is still refused by a red check run, a red backstop, a missing or failed required run, a running run, or a red/superseded record', () => { const f = fixture(); const head = f.shas['1.0.3']!; @@ -234,11 +277,18 @@ describe('release-cut: train evidence gates a commit whose backstop never settle expect(gate(backstop('in_progress', null))).toBe('--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' is still running'); expect(gate([...backstop('completed', 'neutral'), { name: 'slices-report', status: 'queued', conclusion: null }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: check runs still going: slices-report'); expect(gate(backstop('completed', 'neutral'))).toBe('green'); + f.state.checks[head] = []; + const same = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' }); + expect(same.ok ? 'green' : same.message).toBe('--ref ' + head.slice(0, 12) + ' is not green: no ci check run'); + f.state.checks[head] = [{ name: 'ci', status: 'completed', conclusion: 'success' }]; + const sameGreen = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' }); + expect(sameGreen.ok ? sameGreen.plan.via : sameGreen.message).toBe('ci'); for (const state of ['red', 'superseded', 'incomplete', 'free-green']) { note(f, train('t1', state, [{ sha: head, tree }]), head); expect([state, gate(backstop('completed', 'neutral'))]).toEqual([state, '--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' concluded neutral']); } + for (const sha of Object.values(f.shas)) f.state.checks[sha] = backstop('completed', 'neutral'); const none = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); expect(none.ok).toBe(false); diff --git a/test/ship-issue-intent.test.ts b/test/ship-issue-intent.test.ts index 1d07a8325..7263993bd 100644 --- a/test/ship-issue-intent.test.ts +++ b/test/ship-issue-intent.test.ts @@ -27,9 +27,10 @@ describe('issue intent (pure)', () => { }); // Value: protects=a parenthesised named reference "(name #N, #M)", "(name PR #N)" or "(name issue #N)" is a cross-repo reference to that name, never a - // same-repo Refs line or a promotion candidate, while "(see #12)", "(#301)" and "Fixes #12" keep their meaning; fails_when=the bare-#N rule reads the - // numbers as this repository's issues (Refs lines and fix-#N prompts for issues that do not exist here), or a reference word in the parentheses is taken - // for a repository name; why_new=the intent tests cover owner/repo#N as the only cross form; seam=none; no_claim=none beyond protects + // same-repo Refs line or a promotion candidate, while "(see #12)", "(closing #12)", "(cf. #12)", "(v1.2 #3)", "(#301)" and "Fixes #12" keep their + // meaning; fails_when=the bare-#N rule reads the numbers as this repository's issues (Refs lines and fix-#N prompts for issues that do not exist here), or + // a closure, reference, filler or version word in the parentheses is taken for a repository name (the issue then vanishes from Refs and from the fix-#N + // offer as a phantom cross-repo reference); why_new=the intent tests cover owner/repo#N as the only cross form; seam=none; no_claim=none beyond protects test('a parenthesised named reference is a cross-repo reference, never a same-repo Refs or promotion candidate', () => { const named = mentionsIn('ship-queue: screen verdict from the run (gbrain #7625, #7606)\n\nPorts the fix from (gbrain PR #7630) and (acme/tools issue #4).\n'); expect(named.map(x => [x.repo ?? '', x.issue, x.intent])).toEqual([['gbrain', 7625, 'refs'], ['gbrain', 7606, 'refs'], ['gbrain', 7630, 'refs'], ['acme/tools', 4, 'refs']]); @@ -37,7 +38,7 @@ describe('issue intent (pure)', () => { expect(links).toEqual({ fixes: [], refs: [], flakes: [], cross: ['acme/tools#4', 'gbrain#7606', 'gbrain#7625', 'gbrain#7630'], unreadable: [] }); expect(renderIssueLines(links)).toEqual(['Refs acme/tools#4', 'Refs gbrain#7606', 'Refs gbrain#7625', 'Refs gbrain#7630']); expect(promotionCandidates(named, links)).toEqual([]); - for (const word of ['see', 'fixes', 'refs', 'related', 'part of', 'follow-up', 'closes', 'PR', 'issue']) { + for (const word of ['see', 'fixes', 'refs', 'related', 'part of', 'follow-up', 'closes', 'PR', 'issue', 'closing', 'addresses', 'fixing', 'implements', 'cf.', 'e.g.', 'i.e.', 'bug', 'upstream PR', 'v1.2', 'supersedes', 'duplicate of']) { expect([word, mentionsIn(`retry (${word} #12)`).map(x => [x.repo ?? '', x.issue, x.intent])]).toEqual([word, [['', 12, 'refs']]]); } expect(mentionsIn('v1.2.3 (#301) release notes').map(x => [x.repo ?? '', x.issue])).toEqual([['', 301]]);