mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 20:07:08 +01:00
Merge origin/main into garrytan/gsta92-design-motion; stamp v1.91.264.0 (train slot 2)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
5c88b0e649
40 files changed
+2998
-279
No files matched your search
+67
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## [1.91.264.0] - 2026-10-27
|
||||
|
||||
**/design-motion turns an approved design into a showcase loop for X and the website; adapted from Elaya's design-to-motion (GSTA-92 plan item 15, Garry 2026-10-25 "Do it").** gstack could design a screen and build it, but not show it moving: a screen recording drops frames, jumps at the loop seam, and nothing tells you a card drifted out of its container. /design-motion rebuilds the exact frame from a /design-shotgun PNG, a /design-html `finalized.html` or a URL (a Figma link when `FIGMA_TOKEN` is set), loads it from an empty state, gives each visual one story, records frames on a fake clock, and exports only after the loop is proven seamless.
|
||||
|
||||
@@ -15,6 +15,72 @@
|
||||
- `test/motion-pipeline.test.ts`: a 0.5 s fixture loop goes through capture, check, export and compare on real Chromium and ffmpeg, a page that does not loop and a page without the capture contract fail with their named reasons, and the same non-looping capture checks clean under `--min-db 0` only with that override printed. The same `--out` is refused while it holds foreign files, accepts the retry after a refused page, and reports `clean (overflow not checked)` without a mask; a page with an offscreen lazy image renders its still at `--scale 2`; a full-frame mask fails a cards check and is not checked for an app frame, and an empty mask stops the capture before any frame; a spinning `__tick` fails after 10 s; a class-toggled story transition is mid-fade at its midpoint frame; a blank `--min-db` is refused; a failed re-export keeps the last good GIF and a drifting `--gif-fps` is refused. It skips itself when Chromium, ffmpeg or ffprobe is missing. The motion unit tests add the new refusals and a portrait scale.
|
||||
- New paid case `design-motion-showcase` (`test/skill-e2e-design.test.ts`): a finalized page goes through rebuild, capture, checks and export, and the agent must read the fidelity and motion sections and run `check` before `export`. The design-motion carve guard points at it. It runs in the marathon tier as a 3-trial behavior panel, because the periodic census is at its 38-slice cap and three 3-6 minute trials would add two slices; it needs ffmpeg and Chromium on the runner. Its section-read and ordering checks accept a full `cat` of a section and capture, check and export in one Bash call (a free replay of the first panel's three captures passes; a `grep` of a section, no `check`, and export before check still fail).
|
||||
|
||||
## [1.91.263.0] - 2026-10-27
|
||||
|
||||
**Merge trains read their screens from the run, follow themselves without a daemon, and refuse what would have cost a gate.** Operating trains on 2026-10-26 showed the gaps: two screens were VERDICT RED while their lanes reported green and the heads were READY'd anyway; 18 of 41 PR screens concluded success with no slice; a PR whose merge ref lagged main could not be screened at all (#3465); two trains whose PRs merged elsewhere sat in `merging`/`green` forever; a background `gh run watch` chain died with every machine restart; a PR's new tests passed on its branch and failed once stacked behind another PR; and the four-step hand procedure for a conflicting PR's CI was typed by hand each time.
|
||||
|
||||
### Added
|
||||
- **`lib/ship-queue-screen.ts`**: a dispatched screen's verdict is read from the run's own artifacts (`gate-binding-a<attempt>`: verdict, reconcile exit, flaky cases; `report-verdict-a<attempt>/paid-report/collector-outcomes.json`: the panels that failed the lane and the rule-16 flaky lines; a confirmed verdict reuse in `paid-plan/gate-reuse.json`), never from a lane's prose. `ready --screen`, `verify --pr <n> --screen` and `train build` print `SCREEN_VERDICT: #<n> run <id> GREEN|RED|NOT-RUN|INCOMPLETE [red=…] [flaky=…]` plus one `FLAKY:` line per flaky case, and record the verdict in the READY receipt (`screen.verdict`, locally and in `refs/notes/gstack-trains`).
|
||||
- **`QUEUE_SCREEN_RED`**: a RED screen refuses `ready` under `--screen` and `--no-screen` alike, is never re-dispatched at the same merge SHA, and `train build` refuses to seat the pin. **`QUEUE_SCREEN_NOT_RUN`**: a screen run that concluded success with no slice (its route job found the head superseded by a push, or the plan selected nothing) is `NOT-RUN`: `verify --screen` refuses, the receipt records `NOT_RUN`, `ready --screen` dispatches afresh instead of following it, and `train build` seats the pin unscreened with a `SCREEN: … counted as not-run` line.
|
||||
- **Scratch-merge screens (#3465)**: when GitHub's `refs/pull/<n>/merge` lags the PR head, is missing or cannot be fetched, `ready --screen` plans the screen on a scratch merge of the head onto the base, pushed as `screen/<pr>-<head12>` (`SCREEN_MERGE:` names it); only a real conflict with the base (or a remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`, whose text now says so, and a base that cannot be fetched is `POLICY_SOURCE_UNAVAILABLE`.
|
||||
- **`train watch --train <nonce> [--timeout <s>]`** (`lib/ship-queue-train-watch.ts`): the one resumable follow command. It reads the record, runs the step its state asks for (`train build --train` to push, run the free tests and dispatch the gate; `train status` to settle an unconfirmed dispatch), waits for the pending run by polling gh, reads the verdict and prints the last step's `NEXT:`. No process state: rerun it after an interruption and it continues from the record; it never dispatches anything the record would not dispatch on its own.
|
||||
- **`train prune --stale`**: a `merging` or `green` train whose every PR is MERGED or CLOSED on GitHub is marked `stale` (a new terminal state), its branch deleted, each PR's state logged on a `STALE:` line; a train with an OPEN PR is kept and says why. The same run deletes every `screen/<pr>-*` scratch branch whose PR is MERGED or CLOSED. **`train forget --train <nonce> | --receipt <pr>@<sha> [--dry-run]`** (`lib/ship-queue-train-forget.ts`) removes one settled train (attempts included) or one READY receipt from local state and from the notes ref with an exact `--force-with-lease` on `refs/notes/gstack-trains`; a train still building, running or merging is refused, and a rejected lease removes nothing.
|
||||
- **`verify --post-merge <sha> [--timeout <s>]`** (`lib/ship-queue-post-merge.ts`): after a merge, waits for every check run and commit status attached to the merged commit (its full 40-hex SHA) to settle, prints one `CHECK: <name> <conclusion>` per check and `POST_MERGE: <sha12> green|red|pending|none (passed= failed= pending= skipped=)`; green needs a completed check and no failure, `neutral`/`skipped` count as skipped, a pending commit status keeps it pending, a failed check or status is **`QUEUE_POST_MERGE_RED`**, a timeout is `QUEUE_TRAIN_PENDING` with the same command as `NEXT:`.
|
||||
- **`ready … --dispatch-ci <wf.yml[,…]> [--timeout <s>]`** (`lib/ship-queue-dispatch-ci.ts`): for a PR that conflicts with the base and so has no pull_request CI, dispatches each named workflow on the branch (skipped when a run at this head already concluded), waits for the runs (`CI_DISPATCH:` lines), clears every requires-remote pregate lane whose workflow is one of them with the run URL (`PREGATE_CLEARED:`), then reads the head as usual. A red run clears no lane and refuses; a fork head is `QUEUE_TRAIN_UNTRUSTED`. This replaces the four-step hand procedure in docs/ship-policy.md.
|
||||
- **`train build --dry-run --stack-tests [--stack-tests-command "<cmd>"]`** (`lib/ship-queue-stack-tests.ts`): runs each PR's new test files on the integrated prefix that includes it (T<k>) in a scratch worktree and prints `STACK_TESTS: #<pr> T<k> <n> new test file(s) pass|FAIL (<files>)` or `no new test files`; a failure is **`QUEUE_TRAIN_STACK_RED`** with nothing written, pushed or dispatched.
|
||||
|
||||
### Changed
|
||||
- **`lib/ship-queue-train-github.ts`**: `downloadJson` finds a named file anywhere in a multi-path artifact (`report-verdict-a1` keeps its `paid-report/` directory). **`lib/ship-queue.ts`**: `ciAtHead` is exported and returns the per-check list it already read. **`scripts/lib/windows-curation.ts`**: `test/ship-queue-train-ops.test.ts` is excluded from the Windows subset like the other gh-shim drivers.
|
||||
- docs/ship-policy.md's merge-train section documents every command above; docs/troubleshooting.md gains `QUEUE_SCREEN_RED`, `QUEUE_SCREEN_NOT_RUN`, `QUEUE_TRAIN_STACK_RED` and `QUEUE_POST_MERGE_RED`, and `QUEUE_SCREEN_STALE_REF` now means a real conflict only.
|
||||
|
||||
### Tests
|
||||
- `test/ship-queue-train-ops.test.ts` (new; the gh shim in `test/helpers/ship-queue-train-fixture.ts` gained `binding`, `outcomes`, `reuse`, `plan: null` and `ci` rules with the real artifact layout): a RED screen refuses ready under either choice, is printed by verify --screen with its red and flaky cases and refuses the pin in train build; a GREEN screen with flaky cases is followed and recorded; a NOT-RUN screen refuses verify with `QUEUE_SCREEN_NOT_RUN`, records `NOT_RUN` and is re-dispatched; a lagging or missing merge ref gets a scratch merge as `screen/<pr>-<head12>` and a real conflict stays `QUEUE_SCREEN_STALE_REF`; prune --stale settles only an all-merged-or-closed train and keeps one with an OPEN PR; train forget removes a train or a receipt and refuses a merging train; watch resumes from free-pending through the gate to the verdict and never dispatches twice; verify --post-merge waits, is red with the failed names, green when settled, resumable after a timeout; ready --dispatch-ci dispatches, waits, clears the matching lanes, refuses a red run and a fork head; --stack-tests catches a PR whose new test fails on T2 and passes after `--order` moves it first. Each was red before its change.
|
||||
- `test/ship-queue-train.test.ts`: the stale-ref test now proves the scratch merge path; the planned-0-cases test expects the SCREEN: verdict lines.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, rubric, trial count, case kind or judge prompt changed. A screen's verdict is read as evals.yml bound it; nothing here changes what a case must pass.
|
||||
|
||||
## [1.91.262.0] - 2026-10-27
|
||||
|
||||
**The weekly release accepts a commit a green train merged, and five lane frictions seen in operation are gone.** Trains merge faster than the main backstop settles, so most main commits ended with a neutral (`superseded`) `main-backstop` check run and the weekly cut returned `NO_GREEN_COMMIT` for a week of commits every one of which merged through a green train. The evidence already existed in `refs/notes/gstack-trains`; the gate now reads it. Alongside: `gstack-issue-links` no longer turns another repository's `(name #N, #M)` into this repository's Refs lines, `gstack-post` no longer asks for a confirmation on run ids written next to run words, `gstack-post pr-body` no longer wipes a PR body when a lane posts one section, and `gstack-ship-queue ready` finds the pregate verdict a lane recorded from a separate clone.
|
||||
|
||||
### 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 <nonce>`. 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 <name>` (default `free-tests`), `--backstop-check <name>` (default `main-backstop`) and `--notes-ref <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, 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 `<state root>/pregate/by-tree/<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/<tree12>.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 run that holds the tests stage stands in (what `--stage tests --remote` writes): a mirror without the `lanes` check (an `--only timing` or preflight-only run), one recorded against another base, or one run 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.
|
||||
- `docs/troubleshooting.md`: `#redact-run-id-as-phone` (what triggers `pii.phone.e164` on a receipt token or a run id, the positions `gstack-post` allowlists, and the fix), `#gstack-post-pr-body-merge` (what `pr-body` keeps, replaces and adds, and `--replace`) and `#gate-error-no-base` (`GATE_ERROR=no_base` from `gstack-gate-diff`, the printed fetch command, and `SCOPE_ERROR=no_base` from `gstack-diff-scope` for the same cause).
|
||||
|
||||
### Tests
|
||||
- `test/release-cut.test.ts`: on the real bare origin with train records pushed to `refs/notes/gstack-trains`, a neutral backstop plus a record naming the sha, or only the tree, is the candidate and the `CANDIDATE` line names the train; a covered commit is still refused by a red check run, a red backstop, a missing or failed required run, a running run, and a record in state `red`, `superseded`, `incomplete` or `free-green` is not evidence; with no record every neutral commit is skipped with the unchanged text and an older covered commit wins; the three flags rename what the gate reads through `planRelease` and the CLI, and the defaults do not see a record under another ref.
|
||||
- `test/ship-issue-intent.test.ts`: `(gbrain #7625, #7606)`, `(gbrain PR #7630)` and `(acme/tools issue #4)` are cross references with no Refs line and no promotion candidate; `(see #12)`, `(fixes #12)`, `(PR #12)` and the other reference words stay bare refs; `(#301)` and `Fixes #12` are unchanged.
|
||||
- `test/gstack-post.test.ts`: each run-word and chained shape posts; the id without a run word, an id after `see`, a 10-digit number, `+14155551234` and `run +14155551234` still need confirmation. For `pr-body`, through `runPost` with a fake runner answering `gh pr view --json body`: a first-section-only file keeps the receipt and the other sections and the stderr line names them; a same-titled section is replaced in place; a receipt-only file replaces only the receipt; new sections are appended; a `## ` inside a code fence is not a heading; `--replace` overwrites without reading the body; a headingless current body is kept as the leading text; an empty current body posts the file byte for byte; a secret in a kept section is a HIGH refusal on the merged bytes; a failed read is exit 3 with the `--replace` hint; `--replace` on another operation is a usage error.
|
||||
- `test/gstack-ship-queue.test.ts`: a mirror for the current tree under `GSTACK_STATE_ROOT` makes `ready` pass from a stale `--pregate` and from a missing default file, with the `from` suffix; a mirror for another tree leaves `PREGATE_STALE` and `missing` as they were; a current explicit `--pregate` is used as before with no suffix.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, rubric, trial count, case kind or judge prompt changed. The release gate accepts a new kind of evidence (a green, merging or merged train record naming the commit) and relaxes no other requirement; the `gstack-post` allowlist widens only for 11-digit tokens in the listed positions.
|
||||
|
||||
## [1.91.261.0] - 2026-10-27
|
||||
|
||||
**`gstack-restamp --next` moves a PR's quoted `since:` rows when its VERSION already equals the base's (gbrain #7640).** A gbrain PR stamped 0.60.468.0, lost its slot, merged master, and master was 0.60.468.0 by then. At its next slot restamp left the PR's `{ since: '0.60.468.0', … }` notice rows at .468 while the release shipped as .469, and gbrain's `compareReleases` then hid that notice from every user already on .468. Two gaps combined: with VERSION equal on both sides the branch had no stamp of its own, so nothing was rewritten everywhere, and the mention scanner that reads the lines the branch added could not pass the opening quote of a quoted row (`since:\s*(\d…)`), so it found nothing either. `--was 0.60.468.0` was no answer, because it rewrites master's own .468 rows too. The quote fix alone covers that merged-base state (the base's .468 is newer than the branch's .467 fork point, so the scanner names it); the equal-version rule below covers the state the fork-point floor cannot, a branch rebased onto the base after it reached X, or one never stamped.
|
||||
|
||||
### Fixed
|
||||
- **Quoted versions are stamp forms** in both the mention scanner (`tentativeMentions`) and the exact rewrite table (`rewriteStamps`) of `lib/restamp.ts`: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` match like the bare forms and keep their quote style, and an opened quote must close right after X, so `since: '1.2.3-beta'` and a mismatched `since: '1.2.3"` are not the form (the tree receipt and the gate-reuse normalizers share `rewriteStamps`, so a quoted `since:` rewrite classifies as a stamp without a template, and a quoted prerelease change never does); `vX` already accepted a quote before the `v`. The scanner also reads the policy's `stamp_patterns` templates, so a template-only form on a branch-added line is a candidate too.
|
||||
- **The equal-version rule.** When the branch has no stamp of its own (its VERSION equals the base's and no `--was` is given), a stamp-form mention of the base version on a line the branch added is its stale tentative stamp and moves to the allocated version; the same version on a line the base already carries is the base's release and stays. The `stamp_paths` stage detail, printed in the stage table and kept in the journal, names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit's tool output shows which lines moved and why; when an explicit stamp matched nothing the skipped detail now names the version it searched for. The rule reads line ownership, not meaning: a branch-added `Requires vX or later` inside `stamp_paths` on a branch forked at X moves too, visible in `--dry-run` and the counts; `docs/ship-policy.md` says how to write such a reference. The explicit `--was X` path is unchanged and still rewrites X on every line; its help line now says so and names the CHANGELOG re-heading it also drives.
|
||||
- **Branch-added lines are known by position, not text.** `branchAddedLines` records each added line's new-side position beside its text (`parseUnifiedDiff` counts it through any context line a hunk carries, and the diff pins `--inter-hunk-context=0` so a `diff.interHunkContext` setting cannot join hunks), and the stage rewrites by position, so a base line whose text equals a branch-added line stays while the added copy moves, and a CRLF checkout (`core.autocrlf`, `eol=crlf`), whose diff is LF-normalized while the file carries CR, still finds the branch's row instead of reporting `0 moved` and shipping it stale.
|
||||
- **A stamp interrupted after VERSION was written retries as a stamp.** The journal recovery restored the branch's own stamp but kept the already-written VERSION as a stamp when the original was the base version, so the retry rewrote nothing and returned `noop` with the row still stale; it now restores the equal-base provenance (no branch stamp) and the retry completes the stamp.
|
||||
|
||||
### Tests
|
||||
- `test/restamp-mentions.test.ts`: the pure forms (single quote, double quote, bare unchanged, quote style kept, a mismatched quote pair refused, a template overlapping the built-in form counted once, the fork-version floor on quoted mentions) and, on the fixture repo, the reporter's state rebuilt with the real `bin/gstack-restamp`: the other PR takes the slot through the tool, the PR merges main with VERSION equal on both sides, and `--next` moves the PR's quoted row, keeps the base's two rows byte for byte, and says why in the stage detail; the same with the built-in form alone (no `since:` template); a negative control where the PR adds no base-version mention and every base-side quoted row stays; a branch forked at the base version (fork equals base, nothing stamped) whose own base-version row and doc mention move while the base's stay; and `--was` rewriting the base's row as well; then the quoted-prerelease and mismatched-quote boundaries in both the scanner and the table, a branch-added line whose text equals a base line (the base copy stays), a CRLF checkout, and an apply interrupted by `EACCES` after VERSION was written whose retry completes the stamp. Eleven of the file's thirteen cases are red at 1.91.253.0 (the template-overlap and `--was` cases pass there, as they should).
|
||||
- `test/stamp-templates.test.ts`: the quoted `since:` row now counts once with or without the template (it pinned zero without one).
|
||||
|
||||
### Docs
|
||||
- `docs/ship-policy.md`: the `stamp_paths` stage states the quoted forms and the provenance rule (the branch's own stamp everywhere; on branch-added lines, mentions newer than the fork version and, with VERSION equal to the base's, the base version; `--was` as the rewrite-everywhere tool).
|
||||
|
||||
## [1.91.260.0] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.260.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.264.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -286,6 +286,10 @@ gstack_result_anchor() {
|
||||
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
|
||||
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
|
||||
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
|
||||
QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;;
|
||||
QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;;
|
||||
QUEUE_POST_MERGE_RED) printf '%s' 'queue-post-merge-red' ;;
|
||||
QUEUE_SCREEN_RED) printf '%s' 'queue-screen-red' ;;
|
||||
READY_SCREEN_CHOICE_REQUIRED) printf '%s' 'ready-screen-choice-required' ;;
|
||||
PREGATE_TIMING_ASSERTION) printf '%s' 'pregate-timing-assertion' ;;
|
||||
PREGATE_NO_POLICY) printf '%s' 'pregate-no-policy' ;;
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
|
||||
--bump <level> micro|patch|minor|major (default: policy bump, else micro)
|
||||
--queue-mode <mode> stamp-at-merge | claim-at-open (default: policy queue_mode, else claim-at-open)
|
||||
--pr <n> this PR, for --expect-order and the gate-ahead record
|
||||
--was <X> the version the branch's docs mention today (default: the branch's own stamp, else base + bump)
|
||||
--was <X> rewrite X on every line of every stamp file, the base's own lines included, and re-head the CHANGELOG section [X] (default: the branch's own stamp everywhere plus the stale mentions on lines the branch added)
|
||||
--expect-base <sha> refuse with QUEUE_STALE when origin/<base> moved
|
||||
--expect-order <pr,...> refuse with QUEUE_STALE when a predecessor is unmerged or a successor merged
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands)
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* gstack-ship-queue gate-ahead --after <pr> --pr <n> [--predecessor-ref <ref>] [--allow-repo-commands]
|
||||
* gstack-ship-queue gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
|
||||
* gstack-ship-queue gate-ahead --list --pr <n>
|
||||
* gstack-ship-queue train build|status|verdict|bisect|rebuild|prune ... (merge trains; train --help)
|
||||
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
|
||||
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
|
||||
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
|
||||
*
|
||||
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
|
||||
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
|
||||
@@ -47,7 +49,10 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
|
||||
gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
|
||||
bind the gate result to that attempt's gated tree; removes its worktree
|
||||
gate-ahead --list --pr <n> attempts for a PR, newest last (the current one)
|
||||
train build|status|verdict|bisect|rebuild|prune ...
|
||||
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
|
||||
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
|
||||
and one CHECK: line per workflow; resumable, default timeout 2700 s
|
||||
train build|status|verdict|bisect|rebuild|watch|prune ...
|
||||
merge trains, when the base policy declares a train block (train --help;
|
||||
docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen
|
||||
|
||||
@@ -146,6 +151,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; }
|
||||
return train.trainMain(ctx, args);
|
||||
}
|
||||
if (cmd === 'verify' && flag(args, '--post-merge') !== undefined) return (await import('../lib/ship-queue-train-cli')).postMergeMain(ctx, args);
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
|
||||
const gateExit = flag(args, '--gate-exit');
|
||||
if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code');
|
||||
@@ -168,7 +174,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
}));
|
||||
}
|
||||
if (cmd === 'verify') {
|
||||
if (flag(args, '--train')) return (await import('../lib/ship-queue-train-cli')).verifyMain(ctx, args, pr);
|
||||
if (flag(args, '--train') || args.includes('--screen')) return (await import('../lib/ship-queue-train-cli')).verifyMain(ctx, args, pr);
|
||||
const record = flag(args, '--record');
|
||||
return finish('verify', verify(ctx, {
|
||||
pr, gated: flag(args, '--gated'), record: record ? path.resolve(record) : undefined, receipt: flag(args, '--receipt'),
|
||||
|
||||
+26
-1
@@ -298,6 +298,26 @@ the machine's) and every `requires-remote` obligation carries a receipt
|
||||
receipt then carries `pregate` and `requires_remote`. `gstack-pregate verify`
|
||||
runs the same check standalone.
|
||||
|
||||
**By tree.** Every record `gstack-pregate` writes is also mirrored, byte for
|
||||
byte, to `<state root>/pregate/by-tree/<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, 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/<tree12>.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 run that holds the tests stage stands in (what `--stage tests
|
||||
--remote` writes, `checks=[lanes]`; a `--only timing` or preflight-only run
|
||||
is "a run without the tests stage"), and it must be recorded against
|
||||
`origin/<base>` 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 <file>` 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
|
||||
(`ratchets`, `lint`, `links`, `hermetic`, `patches`, `integrator`) are named in
|
||||
@@ -891,7 +911,12 @@ gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--superse
|
||||
Reads `origin/<base>..HEAD`. `Fixes #N` only on explicit closure intent: a
|
||||
`Fixes`/`Closes`/`Resolves #N` trailer line or `--issue N`. A bare mention
|
||||
(a subject's `(#N)` included), a partial fix, a deferred ask and every
|
||||
cross-repo `owner/repo#N` are `Refs`. Same-repo references are printed as
|
||||
cross-repo `owner/repo#N` are `Refs`. 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, is a
|
||||
cross-repo `Refs name#N`, never a same-repo Refs line or a promotion
|
||||
candidate; `(see #12)`, `(closing #12)` and `(#301)` stay bare refs.
|
||||
Same-repo references are printed as
|
||||
promotion candidates (`reference #N ← <commit line> (promote: fix #N)` on
|
||||
stderr, `candidates` in `--json`): the owner's `fix #N` reply reruns with
|
||||
`--issue N`; an unattended run keeps them `Refs` and lists them in the
|
||||
|
||||
+34
-16
@@ -21,21 +21,39 @@ commit on main's first-parent history, moved only by this workflow, only
|
||||
forward.
|
||||
|
||||
**Green** means, on that exact commit: the `free-tests` check run succeeded
|
||||
(the status main requires), the `main-backstop` check run succeeded, every
|
||||
check run on it has completed and none failed, and `refs/notes/gstack-trains`
|
||||
holds no active freeze. The Main backstop gate workflow publishes
|
||||
`main-backstop` on each push to main from a job of its own: success when a
|
||||
green train record covers the tree or the backstop gated it green, neutral
|
||||
when main moved past the push before it was gated (superseded) or a freeze's
|
||||
own backstop run owns it (deferred), failure when it froze the queue or could
|
||||
not finish. Neutral is never green: a commit whose own tree was never gated is
|
||||
not released. A push that gets no backstop run at all (a dependabot push, a
|
||||
policy without a train block) has no `main-backstop` check run and is not
|
||||
green either; a cancelled check run counts as red. A commit with a check run still going is skipped and the next
|
||||
older one is considered, back through 7 days or 200 first-parent commits. The
|
||||
workflow runs no tests and no paid evals of its own; it only reads what the
|
||||
trains and the backstop already recorded. With nothing green in the window it
|
||||
fails and lists why each commit was skipped.
|
||||
(the status main requires), the commit was gated (below), every check run on
|
||||
it has completed and none failed, and `refs/notes/gstack-trains` holds no
|
||||
active freeze. A cancelled check run counts as red. A commit with a check run
|
||||
still going is skipped and the next older one is considered, back through 7
|
||||
days or 200 first-parent commits. The workflow runs no tests and no paid evals
|
||||
of its own; it only reads what the trains and the backstop already recorded.
|
||||
With nothing green in the window it fails and lists why each commit was
|
||||
skipped.
|
||||
|
||||
**Gated** means one of two pieces of evidence. The first is the
|
||||
`main-backstop` check run: the Main backstop gate workflow publishes it on
|
||||
each push to main from a job of its own, success when a green train record
|
||||
covers the tree or the backstop gated it green, neutral when main moved past
|
||||
the push before it was gated (superseded) or a freeze's own backstop run owns
|
||||
it (deferred), failure when it froze the queue or could not finish. The second
|
||||
is a train record in `refs/notes/gstack-trains` (kind `train`, state
|
||||
`green`, `merging` or `merged`) whose `merged[]` lists the commit's sha or
|
||||
its tree: those entries are the main commits `gstack-ship-queue verify
|
||||
--train` confirmed, so a commit a green train merged is gated even when trains
|
||||
merged faster than the backstop could settle and its own backstop run ended
|
||||
neutral or never ran. The `CANDIDATE` line says which: `gated by
|
||||
main-backstop` or `gated by train <nonce>`. Train evidence relaxes nothing
|
||||
else: a red backstop run, a failed or missing `free-tests` run, any other red
|
||||
check run or a run still going keeps the commit out, and a record in any other
|
||||
state (`red`, `superseded`, `free-green`, …) is not evidence. A commit with
|
||||
neither a successful backstop run nor a train record is not released: a
|
||||
dependabot push, a policy without a train block, or a tree that was never
|
||||
gated.
|
||||
|
||||
Another repository runs the same script with its own names:
|
||||
`--required-check <name>` (default `free-tests`), `--backstop-check <name>`
|
||||
(default `main-backstop`) and `--notes-ref <ref>` (default
|
||||
`refs/notes/gstack-trains`). `weekly-release.yml` passes none of them.
|
||||
|
||||
A cut that stopped half-way (tag and `stable` pushed, release not created) is
|
||||
finished by the next run, by cron or by hand: `stable` itself stays a
|
||||
@@ -135,7 +153,7 @@ is exercised against real check runs before it merges.
|
||||
|
||||
| Line | Meaning |
|
||||
|---|---|
|
||||
| `CANDIDATE: <sha> (<date>)` | the commit that will be (or was) released |
|
||||
| `CANDIDATE: <sha> (<date>) gated by main-backstop|train <nonce>` | the commit that will be (or was) released, and which evidence gated it (`[hotfix: --ref]` before `gated by` on a `--ref` cut) |
|
||||
| `NOTHING_NEW: origin/main <sha> is already released as v<X>` | nothing merged since the last release; exit 0 |
|
||||
| `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 (a dry run exits 0: the skips are its answer) |
|
||||
| `REFUSED: non-fast-forward: ...` | the candidate is behind stable; stable only moves forward |
|
||||
|
||||
+104
-22
@@ -43,7 +43,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
|
||||
| `queue_mode` | `claim-at-open` \| `stamp-at-merge` | `claim-at-open` | See [stamp-at-merge](#stamp-at-merge). `claim-at-open` is today's queue (`gstack-next-version` claims a slot when the PR opens). |
|
||||
| `release_tool` | command or `null` | `null` | The repo's own release command (`bun run release:restamp`). Runs in an isolated staging worktree under the [release-tool contract](#release-tool-contract). |
|
||||
| `release_outputs` | globs | `[]` | Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and `stamp_paths`. Anything else is `RESTAMP_OUTPUT_OUTSIDE_ALLOWED`. |
|
||||
| `stamp_paths` | globs | `[]` | Where exact `(was X)`, `since: X` and `vX` mentions of the branch's tentative version are rewritten on a restamp. |
|
||||
| `stamp_paths` | globs | `[]` | Where exact `(was X)`, `since: X` and `vX` mentions of the branch's tentative version (quoted or bare: `since: 'X'` counts) are rewritten on a restamp. |
|
||||
| `changelog` | path or `null` | `CHANGELOG.md` | Re-headed on a restamp: the PR's `## [Unreleased]` (or old-version) section moves to the top as `## [<version>] - <date>`. |
|
||||
| `mirror` | relative path or `null` | `null` | A paired checkout to restamp identically (tier 2: validated and confined today, not yet run). |
|
||||
| `history` | `merge-only` \| `squash-ok` | `squash-ok` | `merge-only` makes `/ship` refuse a squash or rebase plan; `gstack-ship-policy check-history` reports a rewritten branch (`HISTORY_POLICY_VIOLATION`). |
|
||||
@@ -52,7 +52,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
|
||||
| `pr_count` | `{ default_pr_count, split_requires_owner }` | `{1, true}` | The PR-count row in plan reviews. |
|
||||
| `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. |
|
||||
| `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. |
|
||||
| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, `<!-- x-stamp: {version} -->`); matched literally at a version boundary, never as a regex. The restamp writer and the tree receipt use them beside the built-in forms. |
|
||||
| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, `<!-- x-stamp: {version} -->`); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. |
|
||||
| `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). |
|
||||
| `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. |
|
||||
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
|
||||
@@ -187,18 +187,25 @@ READY PR as conflicting each time main stamps; the train merges the CHANGELOG
|
||||
by section, so the lane never merges main for it. The conflict does mean GitHub
|
||||
has no merge ref for the PR, so no `pull_request` CI runs on the head and
|
||||
`ready` prints `ci: none at <head12>` with a `ci-hint:` line. The lane then
|
||||
dispatches the free workflows on the branch (`gh workflow run free-tests.yml
|
||||
--ref <branch> && gh workflow run windows-free-tests.yml --ref <branch>`),
|
||||
clears the requires-remote pregate lanes with those runs
|
||||
(`gstack-pregate clear --remote <lane> --run-url <run>`) and reruns `ready`,
|
||||
which passes because the dispatched check runs attach to the commit; the
|
||||
`ci-hint:` line then says those non-pull_request runs count. The `ci-hint:` line is
|
||||
advisory text for the lane; nothing parses it, and a PR whose head is on a fork
|
||||
gets a hint that leaves the dispatch to the coordinator. `ready --screen` on such a
|
||||
PR refuses, because there is no merge ref to screen (`QUEUE_SCREEN_STALE_REF`,
|
||||
`<remote> has no refs/pull/<n>/merge`); that refusal's "merge <base> into the
|
||||
branch" advice does not apply to a CHANGELOG-only conflict, so the lane READYs
|
||||
with `--no-screen` after the skill's own paid cases ran focused.
|
||||
runs `ready … --dispatch-ci free-tests.yml,windows-free-tests.yml [--timeout
|
||||
<s>]` (under a train policy): it dispatches each named workflow on the branch
|
||||
(`gh workflow run <wf> --ref <branch>`, skipped when a run at this head already
|
||||
concluded), waits for the runs (`CI_DISPATCH: <wf> --ref <branch> run <id> <url>
|
||||
<conclusion>`), clears every requires-remote pregate lane whose workflow is one
|
||||
of them with the run URL (`PREGATE_CLEARED: <lane> <url>`; a lane of another
|
||||
workflow stays uncleared and is named in the refusal), and then reads the head
|
||||
as usual, which passes because the dispatched check runs attach to the commit;
|
||||
the `ci-hint:` line then says those non-pull_request runs count. A red run
|
||||
clears no lane and refuses (`QUEUE_NOT_READY`); a run still going at `--timeout`
|
||||
(default 2700 s) is `QUEUE_TRAIN_PENDING`, and rerunning the command keeps
|
||||
waiting; a fork head is never dispatched here (`QUEUE_TRAIN_UNTRUSTED`). By hand the same four steps are `gh workflow
|
||||
run <wf> --ref <branch>`, wait, `gstack-pregate clear --remote <lane> --run-url
|
||||
<run>`, rerun `ready`. The `ci-hint:` line is advisory text for the lane;
|
||||
nothing parses it. `ready --screen` on such a PR has no merge ref to screen,
|
||||
so it builds the scratch merge described below (`SCREEN_MERGE:`, pushed as
|
||||
`screen/<pr>-<head12>`) and screens that; only a real conflict with the base
|
||||
(or a remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`. A CHANGELOG-only conflict is screenable, and a
|
||||
lane whose skill's own paid cases ran focused may still READY with `--no-screen`.
|
||||
|
||||
`ci:` counts the latest run per check (one app's check name), as GitHub's
|
||||
rollup does: a check that was re-run or superseded on the same commit (a
|
||||
@@ -393,7 +400,7 @@ explicit choice:
|
||||
```bash
|
||||
gstack-ship-queue ready --pr 42 # prints SCREEN: lines and the recommendation, refuses
|
||||
gstack-ship-queue ready --pr 42 --no-screen # READY receipt; the PR rides unscreened
|
||||
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (QUEUE_SCREEN_STALE_REF when GitHub's merge ref lags the head)
|
||||
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (a scratch merge when GitHub's merge ref lags the head; QUEUE_SCREEN_STALE_REF only on a real conflict)
|
||||
```
|
||||
|
||||
Without a choice it prints the `SCREEN:` selection (with
|
||||
@@ -410,10 +417,13 @@ the policy, so a repeat follows the same run and a broad screen never adopts
|
||||
a narrow one. The READY block gains `screen: …`, and the receipt is written
|
||||
locally and to the notes ref. `--dry-run` prints the dispatch without sending
|
||||
it. Before dispatching, `ready` fetches the merge ref and requires its second
|
||||
parent to be the PR head: a missing or lagging merge ref (GitHub stops
|
||||
recomputing it while the PR conflicts with the base, CHANGELOG-only conflicts
|
||||
included) is `QUEUE_SCREEN_STALE_REF`, because a screen dispatched there plans
|
||||
no cases and reports success. A finished screen that planned no cases (no
|
||||
parent to be the PR head and its first parent the current base: a missing,
|
||||
lagging or unfetchable merge ref (GitHub stops recomputing it while the PR
|
||||
conflicts with the base, CHANGELOG-only conflicts included) is replaced by a
|
||||
scratch merge of the head onto the base, pushed as `screen/<pr>-<head12>`
|
||||
(the `SCREEN_MERGE:` line says which); a screen dispatched on a stale ref would
|
||||
plan no cases and report success. Only a real conflict with the base (or a
|
||||
remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`. A finished screen that planned no cases (no
|
||||
`paid-plan` artifact, which a superseded run never uploads, or a plan manifest
|
||||
with nothing planned) is never followed as a screen: `ready --screen`
|
||||
dispatches a fresh one and `train build` prints `SCREEN: #<n> run <id> planned
|
||||
@@ -547,9 +557,81 @@ It runs the normal free tests, full gate and verification on main plus the
|
||||
repair. When verify confirms the repair merged, it prints `FREEZE: cleared`
|
||||
and `NEXT:` builds a train of the PRs still unmerged.
|
||||
|
||||
**5b. Coordinator: after the merge.** `gstack-ship-queue verify --post-merge
|
||||
<sha> [--timeout <s>]` (the merged commit's full 40-character SHA) watches
|
||||
every check run attached to it (the push workflows: free tests, the Windows
|
||||
subset, the main backstop) and every commit status until none is still
|
||||
running, prints one `CHECK: <name> <conclusion>` per check and
|
||||
`POST_MERGE: <sha12> green|red|pending|none (passed= failed= pending= skipped=)`.
|
||||
Green needs at least one completed check and no failure; `neutral` and
|
||||
`skipped` (the backstop superseding itself) count as skipped; a pending
|
||||
commit status keeps the commit pending and a failed one makes it red. A
|
||||
failure is `QUEUE_POST_MERGE_RED` naming the checks; a timeout (default 2700 s) is
|
||||
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`. It keeps no process
|
||||
state, so rerunning it after a machine restart continues the watch.
|
||||
|
||||
**6. Clean up.** `gstack-ship-queue train prune [--train <nonce>] [--dry-run]`
|
||||
deletes the `train/<nonce>` branches of merged, superseded and dropped trains
|
||||
(`WOULD-DELETE:`, `DELETED:`, `GONE:`).
|
||||
(`WOULD-DELETE:`, `DELETED:`, `GONE:`). `train prune --stale` also settles a
|
||||
`merging` or `green` train whose every PR is MERGED or CLOSED on GitHub (its
|
||||
PRs landed through another train or by hand): it logs each PR's state on a
|
||||
`STALE:` line, marks the record `stale` and deletes its branch; a train with an
|
||||
OPEN PR is kept (`KEEP: … still needs the train`). The same run deletes every
|
||||
`screen/<pr>-*` scratch branch whose PR is MERGED or CLOSED and keeps one whose
|
||||
PR is still OPEN. `train forget --train <nonce> | --receipt
|
||||
<pr>@<sha> [--dry-run]` removes one record (a settled train with its attempts,
|
||||
or one READY receipt) from local state and from `refs/notes/gstack-trains`
|
||||
with an exact lease on the notes ref, printing `FORGET:` then `FORGOTTEN:`; a
|
||||
train that is still building, running or merging (`built`, `pushed`,
|
||||
`free-pending`, `free-green`, `dispatch-unknown`, `dispatch-absent`,
|
||||
`gate-pending`, `merging`) is refused; a rejected lease removes nothing and
|
||||
restores the local notes ref.
|
||||
|
||||
**Following a train without a daemon.** `gstack-ship-queue train watch --train
|
||||
<nonce> [--timeout <s>]` is the one resumable follow command: it reads the
|
||||
record, runs the step its state asks for (`train build --train` to push, run
|
||||
the free tests and dispatch the gate; `train status` to settle an unconfirmed
|
||||
dispatch), waits for the pending run by polling gh, reads the verdict and
|
||||
prints the last step's `NEXT:`. It never dispatches anything the record would
|
||||
not dispatch on its own, so rerunning it after an interruption continues from
|
||||
the record (`WATCH:` lines say what it did; a timeout, default 7200 s, is
|
||||
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`; `--free-timeout <s>`,
|
||||
default 2700, bounds the wait for the free tests).
|
||||
|
||||
**Screens are read from the run.** `ready --screen` and `verify --pr <n>
|
||||
--screen` read a dispatched screen's verdict from the run's own artifacts
|
||||
(`gate-binding-a<attempt>`, `report-verdict-a<attempt>`), never from a lane's
|
||||
prose, and record it in the READY receipt (`screen.verdict`): `SCREEN_VERDICT:
|
||||
#<n> run <id> GREEN|RED|NOT-RUN|INCOMPLETE [red=…] [flaky=…]` plus one
|
||||
`FLAKY:` line per rule-16 flaky case. `PENDING` is a run still going,
|
||||
`UNKNOWN` one the queue cannot read, and `INCOMPLETE` a finished run that bound
|
||||
no verdict (its report job did not finish): `ready --screen` dispatches afresh
|
||||
instead of following it, `verify --screen` refuses it (`QUEUE_PROOF_MISSING`)
|
||||
and `train build` seats the pin unscreened, as for `NOT-RUN`. A RED screen refuses `ready` under
|
||||
either choice, is never re-dispatched at the same merge SHA and is not seated
|
||||
by `train build`, whose `NEXT:` names the build without that PR
|
||||
(`QUEUE_SCREEN_RED`); a run that concluded success with no slice (its route
|
||||
job found the head superseded or the plan selected nothing) is `NOT-RUN`:
|
||||
`verify --screen` refuses with `QUEUE_SCREEN_NOT_RUN`, `ready --screen`
|
||||
dispatches afresh instead of following it, and `train build` seats the pin
|
||||
unscreened with a `SCREEN: … counted as not-run` line. When GitHub's
|
||||
`refs/pull/<n>/merge` lags the PR head or is missing, `ready --screen` plans
|
||||
the screen on a scratch merge of the head onto the base, pushed as
|
||||
`screen/<pr>-<head12>` (`SCREEN_MERGE:` names it; `prune --stale` deletes it
|
||||
once the PR merged or closed); only a real conflict with the base (or a remote
|
||||
whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`.
|
||||
|
||||
**Stacked tests before the gate.** `train build --prs … --dry-run --stack-tests
|
||||
[--stack-tests-command "<cmd>"]` runs each PR's new test files (added relative
|
||||
to the base, with a name ending in `.test`, `_test`, `.spec` or `_spec` and a
|
||||
JS or TS extension, bun's own rule, so a helper or fixture under `test/` is not
|
||||
one) on the integrated prefix that includes it, T<k>, in a scratch worktree, and prints
|
||||
`STACK_TESTS: #<pr> T<k> <n> new test file(s) pass|FAIL (<files>)` or `no new
|
||||
test files`. A failure is `QUEUE_TRAIN_STACK_RED` (nothing written, pushed or
|
||||
dispatched): the tests pass on the PR head alone only because nothing before
|
||||
it in the train had changed what they read, so fix them against that prefix or
|
||||
reorder with `--order`. The default command is `bun --no-env-file
|
||||
--config=/dev/null test` with the files appended.
|
||||
|
||||
**Escape hatches.** Remove the `train` block on the base branch to return
|
||||
every lane to the per-PR queue at once. `--urgent` ships one PR alone,
|
||||
@@ -715,7 +797,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
|
||||
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
|
||||
2. **release_tool**: the policy command, under the contract below.
|
||||
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
|
||||
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. `X` is the branch's own tentative stamp (its VERSION when it moved past base), else `--was <X>`, else the version an unstamped branch would have taken next (`base + bump`); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
5. **mirror**: tier 2; declared and validated, reported as not run.
|
||||
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
|
||||
|
||||
@@ -794,7 +876,7 @@ manifest (`version` key only), npm and bun lockfiles (root `version` keys
|
||||
only, where `gstack-version-bump` writes; a dependency entry is `changed`),
|
||||
the CHANGELOG (a heading move or re-heading, compared as an order-independent
|
||||
multiset of entries), `stamp_paths` and `release_outputs` (every
|
||||
removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite with
|
||||
removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite (a quote around X in the first two forms included) with
|
||||
X the gated version and the replacement the current one, so a dependency pin
|
||||
that happens to equal the old version is `changed`; `stamp_patterns`
|
||||
templates count as stamp forms too). With `stamp_files` set, a file outside it
|
||||
|
||||
+211
-19
@@ -2087,6 +2087,29 @@ override is never cached.
|
||||
`gstack-gate-diff` records a candidate ending in a tree id instead of
|
||||
`+nowtree`.
|
||||
|
||||
<a id="gate-error-no-base"></a>
|
||||
### `GATE_ERROR=no_base`
|
||||
|
||||
**Meaning.** `gstack-gate-diff` could not resolve the base it diffs against
|
||||
(`GATE_ERROR=no_base ref=<ref> fix=<git command>` is its only stdout line,
|
||||
exit 2). The ref is not a commit in this checkout: a shallow clone (CI's
|
||||
default `fetch-depth: 1`), a remote branch never fetched
|
||||
(`origin/main` with no `refs/remotes/origin/main`), or a `--commit` whose
|
||||
first parent is missing from a shallow history. The tool never guesses a
|
||||
base, because an empty diff against a wrong or absent base would read as "no
|
||||
gate edits" and pass review. `gstack-diff-scope` prints `SCOPE_ERROR=no_base`
|
||||
(all flags false, exit 2) for the same cause.
|
||||
|
||||
**Fix.** Run the command the line prints. It is built for this checkout:
|
||||
`git fetch origin +<branch>:refs/remotes/origin/<branch>` for an unfetched
|
||||
branch, `git fetch --unshallow origin <refspec>` or
|
||||
`git fetch --deepen=50 origin` when the clone is shallow. In a workflow, check
|
||||
out with `fetch-depth: 0` (or fetch the base explicitly) before the step that
|
||||
runs `gstack-gate-diff` or `gstack-diff-scope`.
|
||||
|
||||
**Expected result.** `gstack-gate-diff <base>` prints `GATE_SUMMARY:` and
|
||||
`gstack-diff-scope` prints its `SCOPE_*` flags without an error line.
|
||||
|
||||
---
|
||||
|
||||
## Memory and gbrain
|
||||
@@ -2330,6 +2353,60 @@ release of this repository (or the post ran outside the checkout).
|
||||
**Fix.** Write it as `version: 1.2.3.4`, `"version": "..."` or `v1.2.3.4`.
|
||||
MEDIUM findings do not block pushes.
|
||||
|
||||
<a id="redact-run-id-as-phone"></a>
|
||||
### `pii.phone.e164` MEDIUM on a receipt token or a GitHub Actions run id
|
||||
|
||||
**Meaning.** An all-digit identifier reads as a phone number: a 12-hex receipt
|
||||
sha that happens to be all digits (a `tree`, `head`, `gated_tree` or
|
||||
`predecessor` value), or an 11-digit GitHub Actions run id written somewhere
|
||||
`gstack-post` cannot tell from a number. `gstack-post` allowlists only the
|
||||
exact tokens it finds in these positions:
|
||||
|
||||
- a 12-digit value right after `tree`, `gated_tree`, `head` or `predecessor`
|
||||
with `=` or a space (`tree=704992822996`, `tree 704992822996`);
|
||||
- an 11-digit id inside an `/actions/runs/<id>` URL or as that link's text;
|
||||
- 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` (`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)`).
|
||||
|
||||
A bare 11-digit number with none of that context, a 10-digit national number
|
||||
and an E.164 `+14155551234` still report, so the finding means the id stands
|
||||
without a run word (`the id is 40269992077`, `40269992077 failed`) or follows
|
||||
a word that is not one (`see 40323989742`).
|
||||
|
||||
**Fix.** Write the id next to a run word (`run 40269992077`) or as an
|
||||
`/actions/runs/` link. For anything else, rerun with `--confirm <token>`
|
||||
using the `TOKEN:` line the refusal printed; MEDIUM findings do not block
|
||||
pushes.
|
||||
|
||||
<a id="gstack-post-pr-body-merge"></a>
|
||||
### `gstack-post pr-body` kept sections the body file did not contain
|
||||
|
||||
**Meaning.** `pr-body` merges the file into the PR's current body by `## `
|
||||
section instead of overwriting it: a section in the file replaces the
|
||||
same-titled section (title compared case-insensitively), 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 stay in
|
||||
place, new sections are appended after them, and a current body with no
|
||||
`## ` headings is kept as the leading text. One stderr line names what was
|
||||
replaced, kept and added (`gstack-post: pr-body merged by section: replaced
|
||||
What changed; kept ship receipt, Why, Approvals; added Tests`). A body file
|
||||
holding only its first section therefore no longer wipes the rest of the PR.
|
||||
The redaction scan runs on the merged bytes, so a MEDIUM or HIGH finding can
|
||||
come from a kept section; the `RULE:` line's `LINE:` is in the merged text.
|
||||
When the current body cannot be read (`gh pr view --json body` fails) nothing
|
||||
is posted, exit 3.
|
||||
|
||||
**Fix.** Nothing, when the kept sections are wanted. To overwrite the whole
|
||||
body with the file, rerun with `--replace`. To drop a section, post the body
|
||||
with `--replace` or edit it on the host.
|
||||
|
||||
<a id="redact-prepush-email"></a>
|
||||
### `MEDIUM pii.email <file>:<line>` from the pre-push hook
|
||||
|
||||
@@ -2566,7 +2643,9 @@ or corrupt object) that `git ls-tree` cannot confirm is absent; it never falls
|
||||
through to the user-owned file. It also refuses when Bun loaded a `.env` file
|
||||
from the working directory (a bare `bun gstack-pregate`), since a branch could
|
||||
then pick the user policy: run `gstack-pregate` directly or as
|
||||
`bun --no-env-file`.
|
||||
`bun --no-env-file`. `gstack-ship-queue ready` under a train policy fetches
|
||||
`origin/<base>` first and refuses with this code, quoting git's error, when
|
||||
that fetch fails.
|
||||
|
||||
**Fix.** `git fetch origin <base> --depth=1`, then rerun. To run against the
|
||||
branch's own file deliberately, pass `--policy-from head --allow-repo-commands`;
|
||||
@@ -3974,7 +4053,9 @@ time; `gstack-ship-policy validate` prints `MIN_READER:` for exactly that.
|
||||
coordinator could misread: the working tree has uncommitted tracked changes,
|
||||
HEAD is not the PR's pushed head (read through `gh pr view`), CI at that
|
||||
exact head is not green (failed, pending or unreadable), or the pregate result
|
||||
is missing, failed or recorded on another tree (`PREGATE_STALE`).
|
||||
is missing, failed or recorded on another tree (`PREGATE_STALE`). Under
|
||||
`--dispatch-ci` it is also a dispatched workflow run that concluded red (a red
|
||||
run clears no pregate lane) or a `gh workflow run` that failed.
|
||||
|
||||
**Fix.** Commit and push, wait for CI on that head, rerun
|
||||
`gstack-pregate run --allow-repo-commands` when the tree changed, then rerun
|
||||
@@ -3988,8 +4069,13 @@ which line is not yet true.
|
||||
input does not match: the gated head is not a commit here, HEAD differs from
|
||||
the gated head beyond stamps (the gate never covered this tree), an earlier
|
||||
stamp with other inputs is unfinished, the stamp journal's head moved, or a
|
||||
gate-ahead merge could not be recomputed with `git merge-tree`. Nothing was
|
||||
written unless the `QUEUE_STAMP_REFUSED:` line says `committed=yes`.
|
||||
gate-ahead merge could not be recomputed with `git merge-tree`. The train
|
||||
commands use it too: `train forget` on a record that does not exist or on a
|
||||
train still building, running or merging, `train watch` on an unknown record,
|
||||
`verify --pr <n> --screen` on a run with no verdict to read (an INCOMPLETE or
|
||||
unreadable run), and `ready --screen` when the scratch merge branch cannot be
|
||||
pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says
|
||||
`committed=yes`.
|
||||
|
||||
**Fix.** Supply the named input (fetch the gated head, pass the current base
|
||||
sha), or regate the tree you mean to stamp. For an unfinished stamp, rerun the
|
||||
@@ -4064,7 +4150,8 @@ a `train` block with `min_gstack_version` on the base branch first
|
||||
|
||||
**Meaning.** A pinned PR's head repository is not the base repository. The
|
||||
train branch and its dispatches run with repository secrets, so fork work
|
||||
never rides a train.
|
||||
never rides a train. `ready --dispatch-ci` refuses a fork head for the same
|
||||
reason and dispatches nothing.
|
||||
|
||||
**Fix.** Run the printed `train build --prs <list without it>`; the fork PR
|
||||
stays on the trusted-branch path.
|
||||
@@ -4128,7 +4215,11 @@ one-PR train.
|
||||
### `QUEUE_TRAIN_PENDING`
|
||||
|
||||
**Meaning.** The train's run (free tests, the full gate or the bisect) has
|
||||
not finished, so there is no verdict yet.
|
||||
not finished, so there is no verdict yet. The same code covers a `train watch`
|
||||
that reached its timeout, `verify --pr <n> --screen` on a screen run still
|
||||
going, `verify --post-merge` on checks that have not settled, and a
|
||||
`ready --dispatch-ci` workflow run still going at its `--timeout`; each prints
|
||||
the command to rerun as `NEXT:`.
|
||||
|
||||
**Fix.** Rerun the printed `NEXT:` command later. Nothing is dispatched again
|
||||
while a run is pending.
|
||||
@@ -4266,10 +4357,14 @@ to fix this: the stamp comes only when the slot is called.
|
||||
<a id="queue-screen-stale-ref"></a>
|
||||
### `QUEUE_SCREEN_STALE_REF`
|
||||
|
||||
**Meaning.** `gstack-ship-queue ready --screen` dispatches the screen against
|
||||
GitHub's `refs/pull/<n>/merge`, and that ref is missing or still merges an
|
||||
older head than the PR's pushed head. GitHub stops recomputing the merge ref
|
||||
while the PR conflicts with the base (a CHANGELOG-only conflict after a stamp
|
||||
**Meaning.** `ready --screen` refuses with this code when the PR conflicts with
|
||||
the base beyond the CHANGELOG and the stamp files (the message lists the
|
||||
conflicting paths) or when it cannot list the remote's refs. A missing or
|
||||
lagging merge ref alone no longer refuses (see Fix). The rest of this
|
||||
paragraph is why such a ref cannot be screened directly: `ready --screen`
|
||||
dispatches the screen against GitHub's `refs/pull/<n>/merge`, and that ref can
|
||||
be missing or still merge an older head than the PR's pushed head. GitHub
|
||||
stops recomputing the merge ref while the PR conflicts with the base (a CHANGELOG-only conflict after a stamp
|
||||
counts), so a screen dispatched there checks out the old merge, sees itself
|
||||
superseded, plans 0 cases and reports success. GSTA-95 hit this on #3431:
|
||||
`ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares
|
||||
@@ -4285,16 +4380,113 @@ read (`… paid-plan artifact has expired`, `… manifest.json is unreadable`, a
|
||||
unreadable run or artifact list, an unreadable repository slug, or a READY
|
||||
whose run id is not a number) keeps the slot screened and prints
|
||||
`SCREEN: #<n> <reason>; counted as screened, unverified`; `ready --screen`
|
||||
follows such a run. When `ready` cannot list the remote's refs or fetch the
|
||||
merge ref at all, it refuses with the same code and a `could not list …` or
|
||||
`could not fetch …` message whose NEXT line says to check the remote, not to
|
||||
resolve a conflict.
|
||||
follows such a run. When `ready` cannot list the remote's refs, it refuses with
|
||||
the same code and a `could not list …` message whose NEXT line says to check
|
||||
the remote, not to resolve a conflict; a merge ref that lists but cannot be
|
||||
fetched is replaced by the scratch merge described under Fix.
|
||||
|
||||
**Fix.** Resolve the conflict with the base branch (or wait for GitHub to
|
||||
recompute the merge ref), push, and run `gstack-ship-queue ready --pr <n>
|
||||
--screen` again. Under crew rule 14 a CHANGELOG-only conflict is left to the
|
||||
train, so a lane that cannot get a fresh merge ref READYs `--no-screen` only
|
||||
after its own focused paid cases passed (rule 6).
|
||||
**Fix.** Since the scratch-merge screen landed, a lagging merge ref is no
|
||||
longer a refusal: `ready --screen` builds the merge of the PR head onto
|
||||
`origin/<base>` itself with the train's merge rules (version files take the
|
||||
base's side, the CHANGELOG merges by section), pushes it as
|
||||
`screen/<pr>-<head12>` and dispatches the screen on that commit, printing
|
||||
`SCREEN_MERGE: scratch merge …`. The code now means a real conflict: the
|
||||
message lists the conflicting paths. Resolve those with the base branch,
|
||||
push, and run `gstack-ship-queue ready --pr <n> --screen` again. A
|
||||
CHANGELOG-only or stamp-file-only conflict does not refuse, unless the PR's
|
||||
CHANGELOG drops or renames a released heading (the message then names the
|
||||
CHANGELOG). `train prune --stale` deletes the scratch branches of merged or
|
||||
closed PRs.
|
||||
|
||||
<a id="queue-screen-red"></a>
|
||||
### `QUEUE_SCREEN_RED`
|
||||
|
||||
**Meaning.** The screen run dispatched for this PR head finished RED. The
|
||||
verdict is read from the run itself, never from a lane's report: the
|
||||
`gate-binding-a<attempt>` artifact evals.yml's report job uploads (verdict,
|
||||
reconcile exit, flaky cases) and the `report-verdict-a<attempt>` artifact's
|
||||
`collector-outcomes.json` (the panels that failed the lane and the rule-16
|
||||
flaky lines). A case the binding calls red already failed twice inside the
|
||||
run (rule 16 reruns a first failure once), so the red is real. `ready`
|
||||
refuses with this code when the head's recorded screen run is red, whichever
|
||||
of `--screen` or `--no-screen` it is given, and never dispatches another
|
||||
screen at the same merge SHA; `train build` refuses to seat the pin and its
|
||||
`NEXT:` names the build without it; `verify --pr <n> --screen` prints
|
||||
`SCREEN_VERDICT: #<n> run <id> RED red=<cases> flaky=<cases>` and one
|
||||
`FLAKY:` line per flaky case. The verdict is recorded in the READY receipt
|
||||
(`screen.verdict`), locally and in `refs/notes/gstack-trains`.
|
||||
|
||||
**Fix.** Read the red cases from the `SCREEN_VERDICT:` line, repair, push a
|
||||
new head, run /ship and `gstack-ship-queue ready --pr <n> --screen` again
|
||||
(the new head gets its own screen). A GREEN screen with flaky cases is green:
|
||||
the flaky cases are listed for the ledger and need no rerun.
|
||||
|
||||
<a id="queue-train-stack-red"></a>
|
||||
### `QUEUE_TRAIN_STACK_RED`
|
||||
|
||||
**Meaning.** `gstack-ship-queue train build --dry-run --stack-tests` ran a PR's
|
||||
new test files (files the PR adds relative to the base whose name ends in
|
||||
`.test`, `_test`, `.spec` or `_spec` plus a JS or TS extension, bun's own
|
||||
rule; a helper or fixture under `test/` is not one) on the integrated prefix that includes it,
|
||||
T<k> (the base plus every slot up to and including that PR), in a scratch
|
||||
worktree, and they failed. The `STACK_TESTS: #<pr> T<k> <n> new test file(s)
|
||||
FAIL (<files>): <failing assertion>` line names the files and the runner's
|
||||
failing lines. Those tests pass on the PR head alone only because nothing
|
||||
before it in the train had changed what they read (a PR stacked on another
|
||||
PR's test files or fixtures hit this on 2026-10-26). Nothing was written,
|
||||
pushed or dispatched; the ordinary dry run (without `--stack-tests`) does not
|
||||
run them.
|
||||
|
||||
**Fix.** Fix the PR's tests against that prefix, or reorder the train with
|
||||
`--order` so the PR they depend on comes first, then rerun the dry run. Pass
|
||||
`--stack-tests-command "<cmd>"` when the repo's tests run under something
|
||||
other than `bun test`; the files are appended to the command.
|
||||
|
||||
<a id="queue-post-merge-red"></a>
|
||||
### `QUEUE_POST_MERGE_RED`
|
||||
|
||||
**Meaning.** `gstack-ship-queue verify --post-merge <sha>` (the full 40-character
|
||||
SHA) watched the check runs attached to a commit the train merged onto the
|
||||
base branch (the push workflows: free tests, the Windows subset, the main
|
||||
backstop) and its commit statuses until none was still running, and at least
|
||||
one check concluded `failure`, `timed_out`, `cancelled` or another non-success,
|
||||
or a commit status failed (the message then says a commit status, not a check
|
||||
run). A `neutral` or `skipped` check (the
|
||||
backstop superseding itself when main moved on) is counted as skipped, never
|
||||
red. The command keeps no process state: it polls gh until the checks settle
|
||||
or `--timeout` (default 2700 s) passes, prints `POST_MERGE: <sha12>
|
||||
green|red|pending|none (passed= failed= pending= skipped=)` and one
|
||||
`CHECK: <name> <conclusion|status>` line per check, and a timeout is
|
||||
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`, so rerunning it after
|
||||
an interruption continues the watch.
|
||||
|
||||
**Fix.** Read the red run named on its `CHECK:` line. Repair on a new PR or
|
||||
roll back the merge; `gstack-ship-queue train status` names the freeze if the
|
||||
backstop filed one.
|
||||
|
||||
<a id="queue-screen-not-run"></a>
|
||||
### `QUEUE_SCREEN_NOT_RUN`
|
||||
|
||||
**Meaning.** The screen run dispatched for this PR concluded `success` but
|
||||
ran no slice. evals.yml's `route` job plans nothing when
|
||||
`scripts/eval-readiness.ts wait` finds the PR head superseded (a push landed
|
||||
between `ready --screen` and the plan) or when the plan selects no case; the
|
||||
run then uploads no `paid-plan` artifact (or an empty manifest), every slice
|
||||
job is skipped and GitHub shows a green check. On 2026-10-26, 18 of 41 PR
|
||||
screens ended this way. The verdict is read from the run (`lib/ship-queue-screen.ts`),
|
||||
so such a run is `NOT-RUN`: `verify --pr <n> --screen` prints
|
||||
`SCREEN_VERDICT: #<n> run <id> NOT-RUN (<why>)` and refuses with this code, the
|
||||
READY receipt records `screen.verdict.outcome: NOT_RUN`, `ready --screen` never
|
||||
follows the run (its `SCREEN_DISPATCH:` line says `not following … a screen
|
||||
that planned nothing is not a screen`) and `train build` seats the pin unscreened
|
||||
with `SCREEN: #<n> run <id> planned no cases …; counted as not-run, the slot
|
||||
rides unscreened`.
|
||||
|
||||
**Fix.** `gstack-ship-queue ready --pr <n> --screen` at the current head
|
||||
dispatches a fresh screen. A head that moved since the dispatch needs a fresh
|
||||
READY anyway (`QUEUE_TRAIN_MOVED`); a head that did not move and still plans
|
||||
nothing has nothing diff-relevant to screen, and `--no-screen` with the
|
||||
focused cases named in the READY block is the honest choice.
|
||||
|
||||
<a id="ready-screen-choice-required"></a>
|
||||
### `READY_SCREEN_CHOICE_REQUIRED`
|
||||
|
||||
+144
-12
@@ -6,6 +6,12 @@
|
||||
* the agent wrote with its file tool; the helper reads each file once, scans
|
||||
* those exact bytes with the shared redaction engine, and sends the same bytes.
|
||||
*
|
||||
* `pr-body` merges by `## ` section into the PR's current body (read through
|
||||
* the same runner): a section in the file replaces the same-titled one, the
|
||||
* ship-receipt block is a section of its own, sections the file does not
|
||||
* rewrite are kept, new ones are appended; `--replace` overwrites instead.
|
||||
* The scan runs on the merged bytes, which are the bytes sent.
|
||||
*
|
||||
* Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused;
|
||||
* 2 MEDIUM findings need confirmation, printed as `RULE:` lines plus a
|
||||
* `TOKEN:` that binds the bytes, destination, operation and findings;
|
||||
@@ -15,6 +21,7 @@
|
||||
*/
|
||||
import { createHash } from "crypto";
|
||||
import { scan, type Finding, type RepoVisibility } from "./redact-engine";
|
||||
import { RECEIPT_FENCE } from "./ship-receipt";
|
||||
import { canonicalRemote } from "./remote-identity";
|
||||
import { parseChangelog } from "./changelog-check";
|
||||
import { versionWidth } from "./version-source";
|
||||
@@ -40,7 +47,7 @@ export const USAGE = `usage: gstack-post <op> [<target>] [options]
|
||||
gstack-post issue-comment <issue> --body-file F
|
||||
gstack-post reply <pr> --to <review-comment-id> --body-file F (GitHub only)
|
||||
gstack-post pr-title <pr> --title-file F
|
||||
gstack-post pr-body <pr> --body-file F
|
||||
gstack-post pr-body <pr> --body-file F [--replace]
|
||||
gstack-post pr-create --base <branch> [--head <branch>] [--draft] --title-file F --body-file F
|
||||
gstack-post issue-create --title-file F --body-file F
|
||||
|
||||
@@ -48,6 +55,9 @@ export const USAGE = `usage: gstack-post <op> [<target>] [options]
|
||||
Options: --host github|gitlab (default: detected from the origin remote)
|
||||
--repo-visibility public|private|unknown (default: config, then gh/glab)
|
||||
--confirm <token> post bytes whose MEDIUM findings the user confirmed
|
||||
--replace pr-body: overwrite the whole body instead of merging it by
|
||||
## section into the current one (the default keeps every
|
||||
section the file does not rewrite, the ship receipt included)
|
||||
|
||||
Write the text files with your file-write tool; never put the text in a
|
||||
shell command. The helper scans the exact bytes it sends.
|
||||
@@ -63,6 +73,7 @@ interface Request {
|
||||
base?: string;
|
||||
head?: string;
|
||||
draft: boolean;
|
||||
replace: boolean;
|
||||
titleFile?: string;
|
||||
bodyFile?: string;
|
||||
host?: HostKind;
|
||||
@@ -80,9 +91,11 @@ function parseArgs(argv: string[]): Request {
|
||||
const flags = new Map<string, string>();
|
||||
const positional: string[] = [];
|
||||
let draft = false;
|
||||
let replace = false;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i]!;
|
||||
if (a === "--draft") { draft = true; continue; }
|
||||
if (a === "--replace") { replace = true; continue; }
|
||||
const eq = a.indexOf("=");
|
||||
const name = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
|
||||
if (VALUE_FLAGS.has(name)) {
|
||||
@@ -100,7 +113,7 @@ function parseArgs(argv: string[]): Request {
|
||||
const vis = flags.get("--repo-visibility");
|
||||
if (vis !== undefined && !["public", "private", "unknown"].includes(vis)) throw new UsageError("--repo-visibility must be public, private or unknown");
|
||||
const req: Request = {
|
||||
op: op as Op, draft,
|
||||
op: op as Op, draft, replace,
|
||||
target: positional[0], to: flags.get("--to"), base: flags.get("--base"), head: flags.get("--head"),
|
||||
titleFile: flags.get("--title-file"), bodyFile: flags.get("--body-file"),
|
||||
host: host as HostKind | undefined, visibility: vis as RepoVisibility | undefined, confirm: flags.get("--confirm"),
|
||||
@@ -116,9 +129,96 @@ function parseArgs(argv: string[]): Request {
|
||||
if (req.to !== undefined && !/^[1-9][0-9]*$/.test(req.to)) throw new UsageError("--to must be a numeric comment id");
|
||||
if ((req.op === "pr-create") !== !!req.base) throw new UsageError(req.op === "pr-create" ? "pr-create needs --base <branch>" : "--base applies to pr-create only");
|
||||
if (req.op !== "pr-create" && (req.head || req.draft)) throw new UsageError("--head and --draft apply to pr-create only");
|
||||
if (req.op !== "pr-body" && req.replace) throw new UsageError("--replace applies to pr-body only");
|
||||
return req;
|
||||
}
|
||||
|
||||
/** One piece of a PR body: the leading text, a `## ` section, the ship-receipt block, or text after a receipt block. */
|
||||
interface Section { key: string; label: string; text: string }
|
||||
|
||||
/**
|
||||
* Split a body into its sections, in order. A `## ` line outside a code
|
||||
* fence opens a section keyed by its title (trimmed, case-insensitive); the
|
||||
* ````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 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 makes the body from that line
|
||||
* on one section the merge never matches (`unclosed-receipt`), so it is kept
|
||||
* byte for byte instead of being read as a receipt or as replaceable text.
|
||||
*/
|
||||
function splitSections(body: string): 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 = () => 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)/)) {
|
||||
bare = line.replace(/\r?\n$/, "");
|
||||
const fenceOpen: RegExpExecArray | null = fence === null ? /^ {0,3}(`{3,}|~{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 (closes("```")) { fence = null; openText(); } continue; }
|
||||
else if (fenceOpen) fence = fenceOpen[1]!;
|
||||
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") Object.assign(out.at(-1)!, { key: "unclosed-receipt", label: "unclosed receipt fence" });
|
||||
return out.filter(s => s.text !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The body to send for `pr-body` without `--replace`: each section of the
|
||||
* 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). 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).filter(n => !(n.key.startsWith("text:") && n.text.trim() === ""));
|
||||
const used = new Set<Section>();
|
||||
const replaced: string[] = [], kept: string[] = [], added: string[] = [];
|
||||
const merged: Section[] = now.map(s => {
|
||||
const match = s.key === "unclosed-receipt" ? undefined : 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;
|
||||
});
|
||||
const fresh = next.filter(n => !used.has(n));
|
||||
for (const n of fresh) added.push(n.label);
|
||||
const first = fresh.filter(n => !n.key.startsWith("h2:"));
|
||||
if (now.length === 0) return { body: incoming, replaced, kept, added };
|
||||
const all = [...first, ...merged, ...fresh.filter(n => n.key.startsWith("h2:"))];
|
||||
const fromFile = (s: Section) => next.includes(s);
|
||||
const body = all.map((s, i) => {
|
||||
const after = all[i + 1];
|
||||
if (!after) return s.text;
|
||||
const seam = (fromFile(s) || fromFile(after)) && s.text.trim() !== "" && !/^\r?\n/.test(after.text);
|
||||
return seam ? s.text.replace(/\n*$/, "\n\n") : s.text.endsWith("\n") ? s.text : s.text + "\n";
|
||||
}).join("");
|
||||
return { body, replaced, kept, added };
|
||||
}
|
||||
|
||||
/** The PR's current body for a merge; GitHub's `--jq .body` prints it raw, GitLab's JSON carries `description`. */
|
||||
function currentBody(env: PostEnv, kind: HostKind, n: string): { body: string } | { error: string } {
|
||||
const r = kind === "github" ? env.run("gh", ["pr", "view", n, "--json", "body", "--jq", ".body"]) : env.run("glab", ["mr", "view", n, "--output", "json"]);
|
||||
if (r.status !== 0) return { error: r.stderr.trim() || `exit ${r.status}` };
|
||||
if (kind === "github") return { body: r.stdout.replace(/\r?\n$/, "") };
|
||||
if (!r.stdout.trim()) return { body: "" };
|
||||
try { return { body: String(JSON.parse(r.stdout).description ?? "") }; } catch { return { error: "glab returned no JSON" }; }
|
||||
}
|
||||
|
||||
interface Remote { host: string; segments: string[]; kind: HostKind }
|
||||
|
||||
function hostedId(url: string) {
|
||||
@@ -229,22 +329,43 @@ function repoReleaseVersions(env: PostEnv): string[] {
|
||||
return [...versions];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* All-digit identifiers a ship receipt or a CI link names, which pii.phone.e164
|
||||
* reads as phone numbers: a 12-hex receipt sha that happens to be all digits
|
||||
* (`tree=704992822996`, `tree 704992822996`, and the receipt's `head=`,
|
||||
* `gated_tree=` and `predecessor=` keys) and an 11-digit GitHub Actions
|
||||
* run id as prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`;
|
||||
* the engine already exempts the id inside the URL itself). Only the exact
|
||||
* tokens found in those positions are allowlisted, so a bare 11-digit number,
|
||||
* a 10-digit national number and an E.164 `+14155551234` still report.
|
||||
* All-digit identifiers a ship receipt or a CI report names, which
|
||||
* pii.phone.e164 reads as phone numbers: a 12-hex receipt sha that happens to
|
||||
* be all digits (`tree=704992822996`, `tree 704992822996`, and the receipt's
|
||||
* `head=`, `gated_tree=` and `predecessor=` keys), an 11-digit GitHub Actions
|
||||
* 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, 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
|
||||
* allowlisted, so a bare 11-digit number, a 10-digit national number and an
|
||||
* E.164 `+14155551234` still report.
|
||||
*/
|
||||
function receiptDigitTokens(texts: string[]): string[] {
|
||||
const tokens = new Set<string>();
|
||||
for (const text of texts) {
|
||||
for (const m of text.matchAll(/(?:^|[^\w])(?:tree|gated_tree|head|predecessor)[= ](\d{12})\b/g)) tokens.add(m[1]!);
|
||||
for (const m of text.matchAll(/\brun #?(\d{11})\b/g)) tokens.add(m[1]!);
|
||||
for (const m of text.matchAll(/\/actions\/runs\/(\d{11})\b/g)) tokens.add(m[1]!);
|
||||
for (const line of text.split("\n")) {
|
||||
let chainFrom = -1;
|
||||
for (const m of line.matchAll(/(?<![\d+])\d{11}(?!\d)/g)) {
|
||||
const at = m.index!;
|
||||
const listed = RUN_WORD_BEFORE.test(line.slice(0, at)) || (chainFrom >= 0 && RUN_ID_CHAIN.test(line.slice(chainFrom, at)));
|
||||
if (listed) tokens.add(m[0]);
|
||||
chainFrom = listed ? at + m[0].length : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...tokens];
|
||||
}
|
||||
@@ -348,7 +469,18 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
|
||||
if (req.op === "reply" && remote.kind !== "github") throw new UsageError("reply is supported on GitHub only");
|
||||
const n = req.target === undefined ? undefined : targetNumber(req.target, req.op, remote);
|
||||
const title = req.titleFile ? readText(env, req.titleFile, "title") : undefined;
|
||||
const body = req.bodyFile ? readText(env, req.bodyFile, "body") : undefined;
|
||||
let body = req.bodyFile ? readText(env, req.bodyFile, "body") : undefined;
|
||||
if (req.op === "pr-body" && !req.replace) {
|
||||
const current = currentBody(env, remote.kind, n!);
|
||||
if ("error" in current) {
|
||||
env.err(`gstack-post: cannot read PR ${n}'s current body to merge into (${current.error}); nothing was posted. Rerun with --replace to overwrite the whole body.\n`);
|
||||
return EXIT.cliFailed;
|
||||
}
|
||||
const m = mergeBodySections(current.body, body!);
|
||||
body = m.body;
|
||||
const list = (names: string[]) => (names.length ? names.join(", ") : "none");
|
||||
env.err(`gstack-post: pr-body merged by section: replaced ${list(m.replaced)}; kept ${list(m.kept)}; added ${list(m.added)}\n`);
|
||||
}
|
||||
const parts: Array<[Part, string]> = [];
|
||||
if (title !== undefined) parts.push(["title", title]);
|
||||
if (body !== undefined) parts.push(["body", body]);
|
||||
|
||||
+16
-2
@@ -9,6 +9,11 @@
|
||||
* - A bare `#N` mention, a `Refs|See|Part of|Partially fixes|Related` line, a
|
||||
* 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,
|
||||
* 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).
|
||||
* - Same-repo references are promotion candidates: the owner's `fix #N`
|
||||
@@ -40,6 +45,10 @@ const CLOSURE = /^\s*(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s*:?\s+(?:#|GH-)(
|
||||
const PARTIAL = /\b(?:partial(?:ly)?|part of|defer(?:red)?|follow-?up|related|refs?|see|context)\b/i;
|
||||
const CROSS = /\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#(\d+)\b/g;
|
||||
const BARE = /(?<![A-Za-z0-9_./-])#(\d+)\b/g;
|
||||
/** `(name #N[, #M]…)`, `(name PR #N)`, `(name issue #N)`: the name, then every number. */
|
||||
const NAMED = /\(([A-Za-z][\w.-]*(?:\/[\w.-]+)?)\s+(?:(?:PR|issue)\s+)?(#\d+(?:,\s*#\d+)*)\)/gi;
|
||||
/** Words that open a reference in parentheses without naming a repository. */
|
||||
const NOT_A_NAME = /^(?:fix(?:es|ed|ing)?|clos(?:e[sd]?|ing)|resolv(?:e[sd]?|ing)|address(?:es|ed|ing)?|implement(?:s|ed|ing)?|supersed(?:es|ed|ing)|replac(?:es|ed|ing)|track(?:s|ed|ing)?|block(?:s|ed|ing)?|dup|duplicates?|bugs?|upstream|refs?|see|related|partial(?:ly)?|part|context|follow-?up|defer(?:red)?|prs?|issues?|gh|via|from|after|since|with|in|by|for|also|cf\.?|e\.?g\.?|i\.?e\.?|and|or|per|of|to|on|at|as|is|was|the|an?|this|that|not|no|vs\.?|like|v\d[\w.-]*)$/i;
|
||||
|
||||
/** Mentions in one commit message: trailer lines carry closure intent, everything else is a reference. */
|
||||
export function mentionsIn(message: string, source = 'commit'): IssueMention[] {
|
||||
@@ -50,8 +59,13 @@ export function mentionsIn(message: string, source = 'commit'): IssueMention[] {
|
||||
const line = raw.replace(/\s+$/, '');
|
||||
const trailer = CLOSURE.exec(line);
|
||||
if (trailer && !PARTIAL.test(line)) { add({ issue: Number(trailer[1]), intent: 'fixes', source: `${source}: ${line.trim()}` }); continue; }
|
||||
for (const c of line.matchAll(CROSS)) add({ issue: Number(c[2]), intent: 'refs', repo: c[1], source: `${source}: ${line.trim()}` });
|
||||
const stripped = line.replace(CROSS, '');
|
||||
const rest = line.replace(NAMED, (whole, name: string, numbers: string) => {
|
||||
if (NOT_A_NAME.test(name)) return whole;
|
||||
for (const n of numbers.matchAll(/#(\d+)/g)) add({ issue: Number(n[1]), intent: 'refs', repo: name, source: `${source}: ${line.trim()}` });
|
||||
return ' ';
|
||||
});
|
||||
for (const c of rest.matchAll(CROSS)) add({ issue: Number(c[2]), intent: 'refs', repo: c[1], source: `${source}: ${line.trim()}` });
|
||||
const stripped = rest.replace(CROSS, '');
|
||||
for (const b of stripped.matchAll(BARE)) add({ issue: Number(b[1]), intent: 'refs', source: `${source}: ${line.trim()}` });
|
||||
}
|
||||
return out;
|
||||
|
||||
+23
-1
@@ -15,6 +15,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts';
|
||||
import { wtreeFingerprint, type WtreeResult } from '../script-invocation';
|
||||
import { resolveStateRoot } from '../state-root';
|
||||
import { guardsCheck } from './checks/guards';
|
||||
import { clearHint, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem, treeMismatch } from './checks/lanes';
|
||||
import { literalsCheck } from './checks/literals';
|
||||
@@ -100,6 +101,18 @@ export function renderTable(file: PregateFile, opts: { lines?: boolean } = { lin
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** Where a pregate record is mirrored by the tree it was recorded against: `<state root>/pregate/by-tree/<tree>.json`. */
|
||||
export function pregateMirrorPath(tree: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveStateRoot(env), 'pregate', 'by-tree', `${tree}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the record, then mirror the same bytes under the state root by tree
|
||||
* (pregateMirrorPath), so a checkout that did not run the pregate (a lane
|
||||
* whose `--remote` run had to happen in a separate clone) can still find the
|
||||
* verdict for its tree through `gstack-ship-queue ready`. The mirror is best
|
||||
* effort: a failure to write it never fails the record.
|
||||
*/
|
||||
export function writePregateFile(repoRoot: string, rel: string, file: PregateFile): string {
|
||||
const abs = path.isAbsolute(rel) ? rel : path.join(repoRoot, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -107,7 +120,16 @@ export function writePregateFile(repoRoot: string, rel: string, file: PregateFil
|
||||
const plain = JSON.parse(JSON.stringify(file));
|
||||
checkSchema(plain, ARTIFACT_SCHEMAS.pregate, '', errors, rel);
|
||||
if (errors.length) throw new Error(`pregate.json does not validate: ${errors.map(e => `${e.path}: ${e.message}`).join('; ')}`);
|
||||
fs.writeFileSync(abs, JSON.stringify(plain, null, 2) + '\n');
|
||||
const json = JSON.stringify(plain, null, 2) + '\n';
|
||||
fs.writeFileSync(abs, json);
|
||||
if (/^[0-9a-f]{12,128}$/.test(file.tree)) {
|
||||
try {
|
||||
const mirror = pregateMirrorPath(file.tree);
|
||||
fs.mkdirSync(path.dirname(mirror), { recursive: true });
|
||||
fs.writeFileSync(`${mirror}.${process.pid}.tmp`, json);
|
||||
fs.renameSync(`${mirror}.${process.pid}.tmp`, mirror);
|
||||
} catch {}
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
|
||||
+139
-63
@@ -209,18 +209,21 @@ function allowedOutput(plan: Plan, root: string, rel: string): boolean {
|
||||
return matchesAny(p.stamp_paths, rel) || matchesAny(p.stamp_files, rel) || matchesAny(p.release_outputs, rel);
|
||||
}
|
||||
|
||||
interface Hunk { removed: string[]; added: string[]; at: number }
|
||||
interface Hunk { removed: string[]; added: string[]; at: number; addedAt: number[] }
|
||||
export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status: 'M' | 'A' | 'D' | 'R'; modeChanged?: boolean }
|
||||
|
||||
/**
|
||||
* Parse `git diff --unified=0` into per-file hunks of removed/added lines (shared with lib/tree-receipt.ts).
|
||||
* File headers come only before a file's first hunk: inside a hunk every `-`/`+` line is content, so a
|
||||
* body line that begins `--` or `++` (`---`/`+++` in the diff) is never dropped as a header.
|
||||
* `addedAt` holds each added line's 1-based position on the new side, counted through any context
|
||||
* line the hunk carries (`diff.interHunkContext` can join hunks under `--unified=0`).
|
||||
*/
|
||||
export function parseUnifiedDiff(text: string): FileDiff[] {
|
||||
const files: FileDiff[] = [];
|
||||
let cur: FileDiff | null = null;
|
||||
let hunk: Hunk | null = null;
|
||||
let cursor = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
||||
@@ -233,45 +236,77 @@ export function parseUnifiedDiff(text: string): FileDiff[] {
|
||||
else if (!hunk && (line.startsWith('old mode ') || line.startsWith('new mode '))) cur.modeChanged = true;
|
||||
else if (line.startsWith('rename to ')) cur.status = 'R';
|
||||
else if (line.startsWith('Binary files')) cur.binary = true;
|
||||
else if (line.startsWith('@@')) { hunk = { removed: [], added: [], at: Number(/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line)?.[1] ?? 0) }; cur.hunks.push(hunk); }
|
||||
else if (line.startsWith('@@')) { hunk = { removed: [], added: [], at: Number(/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line)?.[1] ?? 0), addedAt: [] }; cursor = hunk.at; cur.hunks.push(hunk); }
|
||||
else if (hunk && line.startsWith('-')) hunk.removed.push(line.slice(1));
|
||||
else if (hunk && line.startsWith('+')) hunk.added.push(line.slice(1));
|
||||
else if (hunk && line.startsWith('+')) { hunk.added.push(line.slice(1)); hunk.addedAt.push(cursor); cursor += 1; }
|
||||
else if (hunk && line.startsWith(' ')) cursor += 1;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The lines a branch itself added, per stamp file: `git diff --unified=0 <since> -- <files>` in `cwd` against its working tree. */
|
||||
export function branchAddedLines(cwd: string, since: string, files: string[]): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
/** The lines a branch itself added to one file: their text, and their 1-based positions in the working-tree file. */
|
||||
export interface AddedLines { text: Set<string>; at: Set<number> }
|
||||
|
||||
/**
|
||||
* The lines a branch itself added, per stamp file: `git diff --unified=0 <since> -- <files>` in `cwd`
|
||||
* against its working tree. Positions are the parser's per-line count on the new side (context
|
||||
* lines included, and `--inter-hunk-context=0` keeps a `diff.interHunkContext` setting from joining
|
||||
* hunks), so a base line whose text equals an added line, or a CRLF checkout whose diff is
|
||||
* LF-normalized, is told apart by position.
|
||||
*/
|
||||
export function branchAddedLines(cwd: string, since: string, files: string[]): Map<string, AddedLines> {
|
||||
const out = new Map<string, AddedLines>();
|
||||
if (files.length === 0) return out;
|
||||
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', since, '--', ...files]);
|
||||
const diff = git(cwd, ['diff', '--unified=0', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', since, '--', ...files]);
|
||||
for (const f of parseUnifiedDiff(diff.out)) {
|
||||
const set = out.get(f.path) ?? new Set<string>();
|
||||
for (const h of f.hunks) for (const a of h.added) set.add(a);
|
||||
out.set(f.path, set);
|
||||
const own = out.get(f.path) ?? { text: new Set<string>(), at: new Set<number>() };
|
||||
for (const h of f.hunks) h.added.forEach((a, i) => { own.text.add(a); own.at.add(h.addedAt[i]!); });
|
||||
out.set(f.path, own);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const VERSION_RE = '(?<v>\\d+(?:\\.\\d+){1,3})(?![0-9.])';
|
||||
/**
|
||||
* The branch's tentative stamps: versions it mentions in a stamp pattern, on
|
||||
* lines it added itself, newer than the version at its fork point and not
|
||||
* the allocated one. A released version is never newer than the fork point
|
||||
* of a branch written against it, and the repo's own version width must
|
||||
* match, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
* The built-in stamp forms as scanners, the version in the `v` group: `(was X)`, `since: X` and `vX`.
|
||||
* A quote may wrap X in the first two (`since: 'X'`, `(was "X")`); an opened quote must close right
|
||||
* after X, so `since: '1.2.3-beta'` and `since: '1.2.3"` are not the form.
|
||||
*/
|
||||
export function tentativeMentions(added: Map<string, Set<string>>, forkVersion: string, newVersion: string): string[] {
|
||||
const BUILT_IN_MENTIONS: readonly RegExp[] = [
|
||||
new RegExp(`\\(was (['"]?)${VERSION_RE}(?=\\1\\))`, 'g'),
|
||||
new RegExp(`since:\\s*(['"]?)${VERSION_RE}(?=\\1)`, 'g'),
|
||||
new RegExp(`(?<![A-Za-z0-9.])v${VERSION_RE}`, 'g'),
|
||||
];
|
||||
/** A policy `stamp_patterns` template (one `{version}` slot) as a scanner, the version in the `v` group at a version boundary. */
|
||||
function templateMention(template: string): RegExp {
|
||||
const [pre, post] = template.split(VERSION_SLOT) as [string, string];
|
||||
return new RegExp(`${escapeRegExp(pre)}(?<![0-9.])${VERSION_RE}${escapeRegExp(post)}`, 'g');
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch's tentative stamps: versions it mentions in a stamp form (the
|
||||
* built-in ones and the policy's `stamp_patterns` templates), on lines it
|
||||
* added itself, newer than the version at its fork point and not the
|
||||
* allocated one. A released version is never newer than the fork point of a
|
||||
* branch written against it, and the repo's own version width must match, so
|
||||
* a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
*/
|
||||
export function tentativeMentions(added: Map<string, Set<string>>, forkVersion: string, newVersion: string, templates: readonly string[] = []): string[] {
|
||||
const fork = parseVersion(forkVersion);
|
||||
if (!fork) return [];
|
||||
const width = versionWidth(forkVersion);
|
||||
const scanners = [...BUILT_IN_MENTIONS, ...templates.map(templateMention)];
|
||||
const found = new Set<string>();
|
||||
for (const lines of added.values()) {
|
||||
for (const text of lines) {
|
||||
for (const m of text.matchAll(/(?:\(was |since:\s*|(?<![A-Za-z0-9.])v)(\d+(?:\.\d+){1,3})(?![0-9.])/g)) {
|
||||
const raw = m[1]!;
|
||||
const v = parseVersion(raw);
|
||||
if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue;
|
||||
found.add(raw);
|
||||
for (const re of scanners) {
|
||||
for (const m of text.matchAll(re)) {
|
||||
const raw = m.groups!.v!;
|
||||
const v = parseVersion(raw);
|
||||
if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue;
|
||||
found.add(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,16 +324,19 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string)
|
||||
/**
|
||||
* Exact `(was X)`, `since: X`, `vX` rewrites plus the policy's literal
|
||||
* `stamp_patterns` templates (one `{version}` slot, matched at a version
|
||||
* boundary); anything else in the file stays.
|
||||
* boundary); anything else in the file stays. A quote around X in the
|
||||
* `(was X)` and `since: X` forms (`since: 'X'`, `(was "X")`, the shape of a
|
||||
* quoted source row) is matched and kept as written when it closes right after
|
||||
* X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form); `vX`
|
||||
* already accepts a quote before the `v`.
|
||||
*/
|
||||
export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = []): { text: string; count: number } {
|
||||
const esc = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const v = esc(oldVersion);
|
||||
const patterns: Array<[RegExp, string]> = [
|
||||
[new RegExp(`\\(was ${v}\\)`, 'g'), `(was ${newVersion})`],
|
||||
[new RegExp(`(since:\\s*)${v}(?![0-9.])`, 'g'), `$1${newVersion}`],
|
||||
[new RegExp(`(?<![A-Za-z0-9.])v${v}(?![0-9.])`, 'g'), `v${newVersion}`],
|
||||
...templates.map(t => { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${esc(pre)}(?<![0-9.])${v}(?![0-9.])${esc(post)}`, 'g'), `${pre}${newVersion}${post}`.replace(/\$/g, '$$$$')] as [RegExp, string]; }),
|
||||
const v = escapeRegExp(oldVersion);
|
||||
const patterns: Array<[RegExp, (...m: string[]) => string]> = [
|
||||
[new RegExp(`\\(was (['"]?)${v}\\1\\)`, 'g'), (_, q) => `(was ${q}${newVersion}${q})`],
|
||||
[new RegExp(`(since:\\s*)(['"]?)${v}(?![0-9.])(?=\\2)`, 'g'), (_, pre, q) => `${pre}${q}${newVersion}`],
|
||||
[new RegExp(`(?<![A-Za-z0-9.])v${v}(?![0-9.])`, 'g'), () => `v${newVersion}`],
|
||||
...templates.map(t => { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${escapeRegExp(pre)}(?<![0-9.])${v}(?![0-9.])${escapeRegExp(post)}`, 'g'), () => `${pre}${newVersion}${post}`] as [RegExp, () => string]; }),
|
||||
];
|
||||
let count = 0;
|
||||
let out = text;
|
||||
@@ -396,45 +434,81 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
|
||||
injected('changelog');
|
||||
|
||||
const sp = stage('stamp_paths');
|
||||
const stampGlobs = [...plan.policy.stamp_paths, ...plan.policy.stamp_files];
|
||||
const templates = plan.policy.stamp_patterns;
|
||||
if (stampGlobs.length === 0) { sp.status = 'skipped'; sp.detail = 'no stamp_paths in policy'; }
|
||||
else {
|
||||
const files = git(staging, ['ls-files', '-z']).out.split('\0').filter(f => f && matchesAny(stampGlobs, f));
|
||||
for (const rel of files) {
|
||||
const why = assertInsideRoot(staging, rel);
|
||||
if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`);
|
||||
}
|
||||
const added = branchAddedLines(staging, since, files);
|
||||
const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion));
|
||||
const tentative = new Set(tentativeMentions(added, plan.forkVersion, plan.newVersion).filter(v => !explicit.has(v)));
|
||||
let touched = 0;
|
||||
let total = 0;
|
||||
for (const rel of files) {
|
||||
const abs = path.join(staging, rel);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
||||
const own = added.get(rel);
|
||||
if (explicit.size === 0 && !own) continue;
|
||||
let count = 0;
|
||||
const out = fs.readFileSync(abs, 'utf8').split('\n').map(line => {
|
||||
let text = line;
|
||||
for (const old of explicit) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; }
|
||||
if (own?.has(line)) for (const old of tentative) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; }
|
||||
return text;
|
||||
}).join('\n');
|
||||
if (count) { fs.writeFileSync(abs, out); touched += 1; total += count; }
|
||||
}
|
||||
const olds = [...explicit, ...tentative];
|
||||
sp.status = olds.length ? 'done' : 'skipped';
|
||||
sp.detail = olds.length ? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})` : `no tentative mention newer than the fork version ${plan.forkVersion}`;
|
||||
}
|
||||
rewriteStampPaths(o, plan, staging, since, stage('stamp_paths'));
|
||||
injected('stamp_paths');
|
||||
|
||||
const mi = stage('mirror');
|
||||
mi.status = 'skipped'; mi.detail = plan.policy.mirror ? `tier 2: declared ${plan.policy.mirror}, not run` : 'no mirror in policy';
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamp_paths stage. An explicit old version (the branch's own stamp, or
|
||||
* `--was`) is rewritten on every line of every stamp file; a tentative one
|
||||
* (a mention newer than the fork version) only on lines the branch added.
|
||||
* With no explicit version the branch's VERSION equals the base's: either it
|
||||
* never stamped, or it stamped X and the base reached X on its own (a PR that
|
||||
* lost its slot and merged the base). Then a stamp-form mention of the base
|
||||
* version on a line the branch added is its own tentative stamp, stale now,
|
||||
* and moves; the same version on a line the base already carries is the
|
||||
* base's release and stays. The detail names both counts per file.
|
||||
*/
|
||||
function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since: string, sp: StageRecord): void {
|
||||
const stampGlobs = [...plan.policy.stamp_paths, ...plan.policy.stamp_files];
|
||||
const templates = plan.policy.stamp_patterns;
|
||||
if (stampGlobs.length === 0) { sp.status = 'skipped'; sp.detail = 'no stamp_paths in policy'; return; }
|
||||
const files = git(staging, ['ls-files', '-z']).out.split('\0').filter(f => f && matchesAny(stampGlobs, f));
|
||||
for (const rel of files) {
|
||||
const why = assertInsideRoot(staging, rel);
|
||||
if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`);
|
||||
}
|
||||
const added = branchAddedLines(staging, since, files);
|
||||
const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion));
|
||||
const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v)));
|
||||
const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null;
|
||||
if (equalBase) tentative.add(equalBase);
|
||||
const moved = new Map<string, number>();
|
||||
const baseSide: string[] = [];
|
||||
let touched = 0;
|
||||
let total = 0;
|
||||
for (const rel of files) {
|
||||
const abs = path.join(staging, rel);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
||||
const own = added.get(rel);
|
||||
if (explicit.size === 0 && !own) continue;
|
||||
let count = 0;
|
||||
let ownBase = 0;
|
||||
let keptBase = 0;
|
||||
const rewrite = (text: string, old: string): string => {
|
||||
const r = rewriteStamps(text, old, plan.newVersion, templates);
|
||||
count += r.count;
|
||||
if (r.count) moved.set(old, (moved.get(old) ?? 0) + r.count);
|
||||
return r.text;
|
||||
};
|
||||
const out = fs.readFileSync(abs, 'utf8').split('\n').map((line, i) => {
|
||||
const mine = own?.at.has(i + 1) ?? false;
|
||||
if (equalBase) {
|
||||
const n = rewriteStamps(line, equalBase, plan.newVersion, templates).count;
|
||||
if (mine) ownBase += n; else keptBase += n;
|
||||
}
|
||||
let text = line;
|
||||
for (const old of explicit) text = rewrite(text, old);
|
||||
if (mine) for (const old of tentative) text = rewrite(text, old);
|
||||
return text;
|
||||
}).join('\n');
|
||||
if (ownBase || keptBase) baseSide.push(`${rel}: ${ownBase} moved, ${keptBase} kept`);
|
||||
if (count) { fs.writeFileSync(abs, out); touched += 1; total += count; }
|
||||
}
|
||||
const olds = [...explicit, ...tentative].filter(v => moved.has(v));
|
||||
sp.status = olds.length ? 'done' : 'skipped';
|
||||
const equalNote = equalBase && baseSide.length
|
||||
? `; VERSION equals base ${equalBase}, so ${equalBase} moved only on lines this branch added and stayed on the base's own lines (${baseSide.join('; ')})`
|
||||
: '';
|
||||
const searched = explicit.size ? `no mention of ${[...explicit].join(', ')} in the stamp files and ` : '';
|
||||
sp.detail = olds.length
|
||||
? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}`
|
||||
: `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`;
|
||||
}
|
||||
|
||||
function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] {
|
||||
const apply: StageRecord = { name: 'apply', status: 'pending', writes: [] };
|
||||
journal.stages.push(apply);
|
||||
@@ -520,7 +594,9 @@ export function restamp(o: RestampOptions): RestampResult {
|
||||
if (!o.after && fs.existsSync(journalPath)) {
|
||||
try {
|
||||
const prior: Journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
|
||||
if (prior.inputs?.new_version === plan.newVersion && prior.status !== 'complete' && prior.inputs.old_version !== plan.newVersion) { plan.oldVersion = prior.inputs.old_version; plan.branchStamp = prior.inputs.old_version !== plan.baseVersion ? prior.inputs.old_version : plan.branchStamp; }
|
||||
// A retry after a partial apply sees the already-written VERSION as a branch stamp; the journal holds the
|
||||
// original provenance (the branch's own stamp, or the base version when the branch had none), so restore it.
|
||||
if (prior.inputs?.new_version === plan.newVersion && prior.status !== 'complete' && prior.inputs.old_version !== plan.newVersion) { plan.oldVersion = prior.inputs.old_version; plan.branchStamp = prior.inputs.old_version !== plan.baseVersion ? prior.inputs.old_version : null; }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,26 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'GitHub’s refs/pull/<n>/merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
|
||||
fix: 'resolve the conflict with the base branch (or wait for GitHub to recompute the merge ref), push, then gstack-ship-queue ready --pr <n> --screen again; a lane never READYs on a screen that planned nothing',
|
||||
},
|
||||
QUEUE_SCREEN_NOT_RUN: {
|
||||
anchor: 'queue-screen-not-run',
|
||||
summary: 'the PR’s dispatched screen run concluded success but ran no slice (the run planned no cases: its route job found the PR head superseded by a push, or the plan selected nothing), so the head is not screened and the run is counted as not-run, never as green',
|
||||
fix: 'gstack-ship-queue ready --pr <n> --screen dispatches a fresh screen at the current head (a not-run screen is never followed); train build seats the pin unscreened and says so on its SCREEN: line',
|
||||
},
|
||||
QUEUE_TRAIN_STACK_RED: {
|
||||
anchor: 'queue-train-stack-red',
|
||||
summary: 'train build --dry-run --stack-tests ran a PR’s new test files on the integrated prefix that includes it (T<k>: main plus every slot up to that PR) and they failed; the tests pass on the PR head alone only because nothing before it in the train had changed what they read',
|
||||
fix: 'fix the PR’s tests against that prefix (read the STACK_TESTS: line for the files and the failing assertion), or reorder the train with --order so the PR they depend on comes first, then rerun the dry run; nothing was written, pushed or dispatched',
|
||||
},
|
||||
QUEUE_POST_MERGE_RED: {
|
||||
anchor: 'queue-post-merge-red',
|
||||
summary: 'a push workflow on the merged commit (free tests, the Windows subset, the main backstop) concluded failure, timed out or was cancelled after the train merged it; a neutral or skipped check is not red',
|
||||
fix: 'read the red run (gstack-ship-queue verify --post-merge <sha> prints one CHECK: line per workflow), repair on a new PR or roll back the merge; gstack-ship-queue train status names the freeze if the backstop filed one',
|
||||
},
|
||||
QUEUE_SCREEN_RED: {
|
||||
anchor: 'queue-screen-red',
|
||||
summary: 'the PR head’s dispatched screen run finished RED (its gate binding says so; a case that failed twice is real), so the head is not READY and a train will not seat it',
|
||||
fix: 'read the SCREEN_VERDICT line (gstack-ship-queue verify --pr <n> --screen prints the red and flaky cases), repair, push a new head, run /ship and ready again; a red screen is never re-dispatched at the same merge SHA',
|
||||
},
|
||||
READY_SCREEN_CHOICE_REQUIRED: {
|
||||
anchor: 'ready-screen-choice-required',
|
||||
summary: 'the base policy turns trains on, so READY needs an explicit screen choice: --screen (pay for a diff-relevant screen now) or --no-screen',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `ready --dispatch-ci <workflow.yml[,…]> [--timeout <s>]`: a PR that
|
||||
* conflicts with the base (a CHANGELOG-only conflict after every stamp
|
||||
* counts) has no merge ref, so GitHub runs no pull_request CI on its head and
|
||||
* `ready` prints `ci: none`. The lane used to dispatch the free workflows by
|
||||
* hand, wait, clear the requires-remote pregate lanes with the run URLs and
|
||||
* rerun ready. This does those four steps before `ready` reads the head: one
|
||||
* `gh workflow run <wf> --ref <branch>` per named workflow (skipped when a
|
||||
* run at this head already concluded), a wait for each run, and one
|
||||
* `PREGATE_CLEARED:` per requires-remote lane whose workflow is one of them
|
||||
* and whose run concluded success. A red run clears no lane and refuses. A
|
||||
* fork head is never dispatched here (QUEUE_TRAIN_UNTRUSTED).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { PregateFile } from './headless-artifacts';
|
||||
import { PREGATE_OUT_REL, clearObligation, readPregateFile, writePregateFile } from './pregate/index';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import type { QueueFailure } from './ship-queue-gate';
|
||||
import { prInfo, repoSlug, runView } from './ship-queue-train-github';
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export interface DispatchCiOptions { pr: string; workflows: string[]; pregate?: string; timeoutSec: number; pollMs: number }
|
||||
export type DispatchCiResult = { lines: string[] } | { lines: string[]; failure: QueueFailure };
|
||||
|
||||
interface RunRow { databaseId: number; url: string; status: string; conclusion: string | null; headSha: string }
|
||||
|
||||
function runsAt(ctx: QueueContext, workflow: string, branch: string, head: string): RunRow[] {
|
||||
const r = ctx.gh(['run', 'list', '--workflow', workflow, '--branch', branch, '--event', 'workflow_dispatch', '--limit', '20', '--json', 'databaseId,url,status,conclusion,headSha']);
|
||||
try { return r.ok ? (JSON.parse(r.out) as RunRow[]).filter(x => x.headSha === head) : []; } catch { return []; }
|
||||
}
|
||||
|
||||
export async function dispatchCi(ctx: QueueContext, o: DispatchCiOptions): Promise<DispatchCiResult> {
|
||||
const info = prInfo(ctx, o.pr);
|
||||
if ('error' in info) return { lines: [], failure: { code: 'QUEUE_NOT_READY', message: info.error } };
|
||||
if (!info.headRefName) return { lines: [], failure: { code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s branch through gh (no headRefName)` } };
|
||||
if (info.isCrossRepository) return { lines: [], failure: { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr}'s head is on a fork; its workflows are not dispatched here (the coordinator decides)` } };
|
||||
const head = info.headRefOid;
|
||||
const branch = info.headRefName;
|
||||
const slug = repoSlug(ctx);
|
||||
const deadline = Date.now() + o.timeoutSec * 1000;
|
||||
const lines: string[] = [];
|
||||
const concluded = new Map<string, RunRow>();
|
||||
for (const wf of o.workflows) {
|
||||
const atHead = runsAt(ctx, wf, branch, head);
|
||||
const prior = atHead.find(r => r.status === 'completed');
|
||||
if (prior) { lines.push(`CI_DISPATCH: ${wf} not dispatched (run ${prior.databaseId} already concluded ${prior.conclusion ?? 'unknown'} at this head)`); concluded.set(wf, prior); continue; }
|
||||
const live = atHead[0];
|
||||
if (!live) {
|
||||
const d = ctx.gh(['workflow', 'run', wf, '--ref', branch]);
|
||||
if (!d.ok) return { lines, failure: { code: 'QUEUE_NOT_READY', message: `gh workflow run ${wf} --ref ${branch} failed (${(d.err ?? d.out).trim().replace(/\s+/g, ' ').slice(0, 120) || 'no output'})` } };
|
||||
}
|
||||
let run: RunRow | undefined = live;
|
||||
for (;;) {
|
||||
run = run && slug ? runView(ctx, slug, run.databaseId) ?? run : runsAt(ctx, wf, branch, head)[0];
|
||||
if (run?.status === 'completed') break;
|
||||
if (Date.now() >= deadline) return { lines: [...lines, `CI_DISPATCH: ${wf} --ref ${branch} ${run ? `run ${run.databaseId} ${run.url} ${run.status}` : 'no run appeared'}`], failure: { code: 'QUEUE_TRAIN_PENDING', message: `${wf} ${run ? `run ${run.databaseId} is still ${run.status}` : 'has no workflow_dispatch run at this head yet'} at the --dispatch-ci timeout; rerun ready --dispatch-ci to continue waiting` } };
|
||||
await sleep(o.pollMs);
|
||||
}
|
||||
lines.push(`CI_DISPATCH: ${wf} --ref ${branch} run ${run.databaseId} ${run.url} ${run.conclusion ?? 'unknown'}`);
|
||||
concluded.set(wf, run);
|
||||
}
|
||||
const red = [...concluded.entries()].filter(([, r]) => r.conclusion !== 'success');
|
||||
if (red.length) return { lines, failure: { code: 'QUEUE_NOT_READY', message: red.map(([wf, r]) => `${wf} run ${r.databaseId} concluded ${r.conclusion ?? 'unknown'} at ${head.slice(0, 12)}`).join('; ') + '; a red run clears no lane' } };
|
||||
const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL);
|
||||
if (!fs.existsSync(pregateFile)) return { lines };
|
||||
const read = readPregateFile(pregateFile);
|
||||
if (!read.file) return { lines };
|
||||
let file: PregateFile = read.file;
|
||||
let changed = false;
|
||||
for (const lane of (file.requires_remote ?? []).filter(l => !l.receipt && concluded.has(l.workflow))) {
|
||||
const run = concluded.get(lane.workflow)!;
|
||||
file = clearObligation(file, lane.lane, run.url, `gstack-ship-queue ready --dispatch-ci ${new Date().toISOString()}`);
|
||||
lines.push(`PREGATE_CLEARED: ${lane.lane} ${run.url}`);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) writePregateFile(ctx.repoRoot, pregateFile, file);
|
||||
return { lines };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* `verify --post-merge <sha> [--timeout <s>]`: the coordinator's follow
|
||||
* command after a train merged a PR onto the base branch. The push workflows
|
||||
* run on the merged commit (free tests, the Windows subset, the main backstop
|
||||
* that supersedes itself when main moves on), and a background `gh run
|
||||
* watch` chain dies with the machine that started it. This command keeps no
|
||||
* state: it reads every check run attached to the sha, waits (polling gh)
|
||||
* until none is still running or the timeout passes, prints one `CHECK:`
|
||||
* line per check, and a `POST_MERGE:` summary that is green only when at
|
||||
* least one check completed and none failed; a neutral or skipped check is
|
||||
* skipped, never red. Commit statuses count too: a pending status keeps the
|
||||
* commit pending, a failed one makes it red (named as a commit status when no
|
||||
* check run failed). Rerun it after an interruption and it continues.
|
||||
*/
|
||||
import { ciAtHead, type QueueContext } from './ship-queue';
|
||||
import { outFailed, outOk, outPending, type TrainOut } from './ship-queue-train';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export interface PostMergeOptions { sha: string; timeoutSec: number; pollMs: number }
|
||||
|
||||
export async function verifyPostMerge(ctx: QueueContext, o: PostMergeOptions): Promise<TrainOut> {
|
||||
const deadline = Date.now() + o.timeoutSec * 1000;
|
||||
const at = o.sha.slice(0, 12);
|
||||
const again = `${Q} verify --post-merge ${o.sha} --timeout ${o.timeoutSec}`;
|
||||
for (;;) {
|
||||
const ci = ciAtHead(ctx, o.sha);
|
||||
const lines = [`POST_MERGE: ${ci.line.replace(/^(\S+) at ([0-9a-f]+)/, '$2 $1')}`, ...ci.checks.map(c => `CHECK: ${c.name} ${c.status === 'completed' ? c.conclusion ?? 'unknown' : c.status}`)];
|
||||
const settled = !ci.none && !/^(pending|unknown) /.test(ci.line) && !ci.checks.some(c => c.status !== 'completed');
|
||||
if (settled && ci.green) return outOk(lines, 'none', 'none');
|
||||
if (settled) {
|
||||
const red = ci.checks.filter(c => c.status === 'completed' && !['success', 'skipped', 'neutral'].includes(c.conclusion ?? '')).map(c => c.name);
|
||||
return outFailed({ code: 'QUEUE_POST_MERGE_RED', message: `main's push workflows at ${at} are red: ${red.join(', ') || 'a commit status (no check run failed)'}` }, lines,
|
||||
`read the red run, repair on a new PR or roll back; ${Q} train status names the freeze if the backstop filed one`, 'none');
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
const pending = ci.checks.filter(c => c.status !== 'completed').map(c => c.name);
|
||||
const message = ci.line.startsWith('unknown') ? `the check runs at ${at} are unreadable (${ci.line.replace(/^unknown at [0-9a-f]+ /, '')})`
|
||||
: ci.none ? `no check run has attached to ${at} yet` : `main's push workflows at ${at} have not settled at the timeout: ${pending.join(', ') || 'a commit status'} still running`;
|
||||
return outPending({ code: 'QUEUE_TRAIN_PENDING', message }, lines, again, 'none');
|
||||
}
|
||||
await sleep(o.pollMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* A dispatched screen's verdict, read from the run itself:
|
||||
* never from a lane's prose. evals.yml's report job publishes
|
||||
* `gate-binding-a<attempt>` (scripts/gate-reuse.ts: verdict, reconcile exit,
|
||||
* flaky cases) and `report-verdict-a<attempt>` (collector-outcomes.json: the
|
||||
* panels and the rule-16 flaky lines) on every executed screen; a superseded
|
||||
* screen plans nothing and uploads neither (hollowScreen): it concluded
|
||||
* success with no slice, so its outcome is `not-run`, never green. The outcome is
|
||||
* recorded in the READY receipt (ScreenChoice.verdict) by `ready`,
|
||||
* `verify --screen` and `train build`, so the controller reads one line.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { REUSE_FILE, parseBinding, parseReuse } from '../scripts/gate-reuse';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { downloadJson, hollowScreen, runView } from './ship-queue-train-github';
|
||||
import { nowIso, type ScreenChoice, type ScreenVerdictRecord } from './ship-queue-train-record';
|
||||
|
||||
export type ScreenOutcome =
|
||||
| { kind: 'pending'; detail: string }
|
||||
| { kind: 'not-run'; detail: string }
|
||||
| { kind: 'unknown'; detail: string }
|
||||
| { kind: 'incomplete'; detail: string; flaky: string[] }
|
||||
| { kind: 'green'; detail: string; flaky: string[]; flakyLines: string[] }
|
||||
| { kind: 'red'; detail: string; red: string[]; flaky: string[]; flakyLines: string[] };
|
||||
|
||||
const BINDING = 'gate-binding';
|
||||
const REPORT = 'report-verdict';
|
||||
/** The plan artifact; a confirmed verdict reuse (R2a/R2) leaves its record there and binds nothing new. */
|
||||
const PLAN = 'paid-plan';
|
||||
|
||||
/** The newest attempt's artifact of a name family (`<name>-a<attempt>`); null when the run uploaded none or the list is unreadable. */
|
||||
function newestArtifact(ctx: QueueContext, slug: string, run: number, name: string): string | null | 'unreadable' {
|
||||
const artifacts: Array<{ name?: string; expired?: boolean }> = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const r = ctx.gh(['api', `repos/${slug}/actions/runs/${run}/artifacts?per_page=100&page=${page}`]);
|
||||
let list: { artifacts?: Array<{ name?: string; expired?: boolean }> } | null = null;
|
||||
try { list = r.ok ? JSON.parse(r.out) : null; } catch {}
|
||||
if (!Array.isArray(list?.artifacts)) return 'unreadable';
|
||||
artifacts.push(...list.artifacts);
|
||||
if (list.artifacts.length < 100) break;
|
||||
}
|
||||
const hits = artifacts.map(a => ({ name: a.name ?? '', expired: !!a.expired, attempt: Number(new RegExp(`^${name}-a(\\d+)$`).exec(a.name ?? '')?.[1]) }))
|
||||
.filter(a => Number.isSafeInteger(a.attempt)).sort((a, b) => b.attempt - a.attempt);
|
||||
if (!hits.length) return null;
|
||||
return hits[0]!.expired ? 'unreadable' : hits[0]!.name;
|
||||
}
|
||||
|
||||
interface Outcomes { panels?: Array<{ case?: string; status?: string; failsLane?: boolean; redClass?: string | null }>; flakyLines?: string[] }
|
||||
|
||||
/** Read one screen run: status, plan, gate binding, panels. */
|
||||
export function screenVerdict(ctx: QueueContext, slug: string, run: number): ScreenOutcome {
|
||||
const view = runView(ctx, slug, run);
|
||||
if (!view) return { kind: 'unknown', detail: `run ${run} is unreadable` };
|
||||
if (view.status !== 'completed') return { kind: 'pending', detail: `run ${run} is ${view.status}` };
|
||||
const plan = hollowScreen(ctx, slug, run);
|
||||
if (plan.kind === 'hollow') return { kind: 'not-run', detail: plan.reason };
|
||||
if (plan.kind === 'unknown') return { kind: 'unknown', detail: plan.reason };
|
||||
const bindingName = newestArtifact(ctx, slug, run, BINDING);
|
||||
if (bindingName === 'unreadable') return { kind: 'unknown', detail: `run ${run}'s artifact list is unreadable or its ${BINDING} artifact has expired` };
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-screen-verdict-'));
|
||||
let binding: ReturnType<typeof parseBinding> = null;
|
||||
let outcomes: Outcomes | null = null;
|
||||
try {
|
||||
if (!bindingName) {
|
||||
const reuse = parseReuse(downloadJson(ctx, slug, run, PLAN, scratch, REUSE_FILE));
|
||||
if (reuse && view.conclusion === 'success') {
|
||||
const flaky = [...(reuse.reused_flaky ?? [])].sort();
|
||||
return { kind: 'green', detail: `reused the GREEN verdict of run ${reuse.reused_run_id} (${reuse.kind}): ${reuse.why}${flaky.length ? `, flaky ${flaky.length}` : ''}`, flaky, flakyLines: [] };
|
||||
}
|
||||
return { kind: 'incomplete', detail: `run ${run} concluded ${view.conclusion ?? 'unknown'} and bound no verdict (no ${BINDING} artifact: the report job did not finish)`, flaky: [] };
|
||||
}
|
||||
binding = parseBinding(downloadJson(ctx, slug, run, bindingName, scratch, 'gate-binding.json'));
|
||||
const reportName = newestArtifact(ctx, slug, run, REPORT);
|
||||
outcomes = typeof reportName === 'string' ? downloadJson(ctx, slug, run, reportName, scratch, 'collector-outcomes.json') as Outcomes | null : null;
|
||||
} finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
||||
if (!binding) return { kind: 'unknown', detail: `run ${run}'s ${bindingName} artifact is unreadable` };
|
||||
const flaky = [...(binding.flaky_cases ?? [])].sort();
|
||||
const flakyLines = Array.isArray(outcomes?.flakyLines) ? outcomes!.flakyLines!.filter((l): l is string => typeof l === 'string') : [];
|
||||
const red = (outcomes?.panels ?? []).filter(p => typeof p.case === 'string' && (p.failsLane === true || (p.status === 'FAIL' && p.redClass === 'VERDICT'))).map(p => p.case!).sort();
|
||||
const base = `verdict ${binding.verdict}, reconcile exit ${binding.reconcile_exit}, run concluded ${view.conclusion ?? 'unknown'}${flaky.length ? `, flaky ${flaky.length}` : ''}`;
|
||||
if (binding.verdict === 'GREEN' && binding.reconcile_exit === 0 && view.conclusion === 'success') return { kind: 'green', detail: base, flaky, flakyLines };
|
||||
if (binding.verdict === 'RED' || binding.reconcile_exit !== 0 || red.length) return { kind: 'red', detail: base, red, flaky, flakyLines };
|
||||
return { kind: 'incomplete', detail: base, flaky };
|
||||
}
|
||||
|
||||
/** The receipt field for an outcome that is settled (a pending or unknown run records nothing). */
|
||||
export function verdictRecord(o: ScreenOutcome, run: number): ScreenVerdictRecord | null {
|
||||
if (o.kind === 'pending' || o.kind === 'unknown') return null;
|
||||
const outcome = o.kind === 'green' ? 'GREEN' : o.kind === 'red' ? 'RED' : o.kind === 'not-run' ? 'NOT_RUN' : 'INCOMPLETE';
|
||||
return { outcome, run, red: o.kind === 'red' ? o.red : [], flaky: 'flaky' in o ? o.flaky : [], detail: o.detail, read_at: nowIso() };
|
||||
}
|
||||
|
||||
/** `SCREEN_VERDICT:` and the per-case `FLAKY:` lines for one PR's screen. */
|
||||
export function verdictLines(pr: string, run: number, o: ScreenOutcome): string[] {
|
||||
const word = o.kind === 'green' ? 'GREEN' : o.kind === 'red' ? 'RED' : o.kind.toUpperCase();
|
||||
const red = o.kind === 'red' && o.red.length ? ` red=${o.red.join(',')}` : '';
|
||||
const flaky = 'flaky' in o && o.flaky.length ? ` flaky=${o.flaky.join(',')}` : '';
|
||||
const lines = [`SCREEN_VERDICT: #${pr} run ${run} ${word}${red}${flaky} (${o.detail})`];
|
||||
if ('flakyLines' in o) for (const l of o.flakyLines) lines.push(`FLAKY: ${l}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Apply a read outcome to a receipt's screen choice; returns whether the receipt changed. */
|
||||
export function recordVerdict(screen: ScreenChoice, o: ScreenOutcome): boolean {
|
||||
if (!screen.run) return false;
|
||||
const rec = verdictRecord(o, screen.run);
|
||||
if (!rec) return false;
|
||||
const same = screen.verdict && screen.verdict.outcome === rec.outcome && screen.verdict.run === rec.run && screen.verdict.red.join() === rec.red.join() && screen.verdict.flaky.join() === rec.flaky.join();
|
||||
if (same) return false;
|
||||
screen.verdict = rec;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `train build --dry-run --stack-tests`: before a train spends a gate, run
|
||||
* each PR's new test files on the integrated prefix that includes it (T<k>,
|
||||
* the merge of main and every slot up to and including the PR), not on the
|
||||
* PR head alone. A test that passes on its branch and fails once an earlier
|
||||
* slot changed what it reads is a stack red, caught here for the price of a
|
||||
* few test files instead of a gate. Each prefix is checked out in a scratch
|
||||
* worktree (the repo's node_modules linked in when it has one), the files run
|
||||
* with the test command (`bun --no-env-file --config=/dev/null test` by
|
||||
* default; `--stack-tests-command "<cmd>"` for another runner, the files
|
||||
* appended), and the worktree is removed. A PR that adds no test file is
|
||||
* reported as such and never fails.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { bunIsolationArgs } from './bun-isolation';
|
||||
import { git } from './ship-queue-gate';
|
||||
import type { TrainPrefix, TrainSlot } from './ship-queue-train-record';
|
||||
|
||||
export const DEFAULT_STACK_TEST_COMMAND = ['bun', ...bunIsolationArgs(), 'test'];
|
||||
/** Bun's own test-file rule: the name ends in `.test`, `_test`, `.spec` or `_spec` before its extension; a helper or fixture under test/ is not a test file and would make `bun test <file>` exit 1 for matching nothing. */
|
||||
const TEST_FILE = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
|
||||
|
||||
export interface StackTestResult { pr: string; prefix: number; files: string[]; ok: boolean; detail: string }
|
||||
|
||||
/** Test files the PR adds relative to the base (added, not modified). */
|
||||
export function newTestFiles(repoRoot: string, baseSha: string, head: string): string[] {
|
||||
const r = git(repoRoot, ['diff', '--name-only', '--diff-filter=A', `${baseSha}...${head}`]);
|
||||
return r.ok ? r.out.split('\n').map(s => s.trim()).filter(f => f && TEST_FILE.test(f)) : [];
|
||||
}
|
||||
|
||||
export function stackTests(repoRoot: string, baseSha: string, slots: TrainSlot[], prefixes: TrainPrefix[], command: string[] = DEFAULT_STACK_TEST_COMMAND, timeoutMs = 600_000): StackTestResult[] {
|
||||
const results: StackTestResult[] = [];
|
||||
for (const [i, slot] of slots.entries()) {
|
||||
const prefix = prefixes[i + 1];
|
||||
if (!prefix) break;
|
||||
const files = newTestFiles(repoRoot, baseSha, slot.head);
|
||||
if (!files.length) { results.push({ pr: slot.pr, prefix: prefix.index, files, ok: true, detail: 'no new test files' }); continue; }
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stack-tests-'));
|
||||
try {
|
||||
const add = git(repoRoot, ['worktree', 'add', '--detach', '-q', dir, prefix.commit]);
|
||||
if (!add.ok) { results.push({ pr: slot.pr, prefix: prefix.index, files, ok: false, detail: `cannot check out T${prefix.index}: ${add.err.slice(0, 160)}` }); continue; }
|
||||
const modules = path.join(repoRoot, 'node_modules');
|
||||
if (fs.existsSync(modules) && !fs.existsSync(path.join(dir, 'node_modules'))) fs.symlinkSync(modules, path.join(dir, 'node_modules'), 'dir');
|
||||
const [bin, ...args] = command;
|
||||
const run = spawnSync(bin!, [...args, ...files.map(f => `./${f}`)], { cwd: dir, encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, CI: process.env.CI ?? '1' }, maxBuffer: 64 * 1024 * 1024 });
|
||||
const tail = `${run.stdout ?? ''}\n${run.stderr ?? ''}`.split('\n').filter(l => /^\(fail\)|^\s*\d+ (pass|fail)$|^error:/.test(l)).slice(-6).join('; ');
|
||||
results.push({ pr: slot.pr, prefix: prefix.index, files, ok: run.status === 0, detail: run.status === 0 ? 'pass' : run.error ? `${run.error.message}` : tail || `exit ${run.status}` });
|
||||
} finally {
|
||||
git(repoRoot, ['worktree', 'remove', '--force', dir]);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** One `STACK_TESTS:` line per slot. */
|
||||
export function stackTestLine(r: StackTestResult): string {
|
||||
if (!r.files.length) return `STACK_TESTS: #${r.pr} T${r.prefix} no new test files`;
|
||||
return `STACK_TESTS: #${r.pr} T${r.prefix} ${r.files.length} new test file(s) ${r.ok ? 'pass' : 'FAIL'} (${r.files.join(', ')})${r.ok ? '' : `: ${r.detail}`}`;
|
||||
}
|
||||
@@ -10,12 +10,15 @@ import { EXIT, formatResult } from './headless-artifacts';
|
||||
import { RESULT_CODES } from './result-codes';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { buildTrain, pruneTrains, trainStatus, type TrainOut } from './ship-queue-train';
|
||||
import { trainReady } from './ship-queue-train-ready';
|
||||
import { trainReady, verifyScreen } from './ship-queue-train-ready';
|
||||
import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verdict';
|
||||
import { verifyTrain } from './ship-queue-train-verify';
|
||||
import { trainWatch } from './ship-queue-train-watch';
|
||||
import { verifyPostMerge } from './ship-queue-post-merge';
|
||||
import { trainForget } from './ship-queue-train-forget';
|
||||
import { redactCause } from './ship-queue-train-record';
|
||||
|
||||
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
|
||||
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...] [--urgent | --repair] [--dry-run [--stack-tests [--stack-tests-command "<cmd>"]]] [--free-timeout <s>]
|
||||
--order: explicit slot order, every PR in --prs once (default: screened
|
||||
first, then READY order)
|
||||
train build --train <nonce> resume a train (push, free tests, dispatch) from its record
|
||||
@@ -27,12 +30,27 @@ export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...]
|
||||
train bisect --train <nonce> --spend-cap <usd> [--dry-run]
|
||||
red cases on every prefix T0..Tk, resumable and capped
|
||||
train rebuild --train <nonce> [--infra] the one rebuild an all-green bisect earns (or the one INFRA re-dispatch)
|
||||
train prune [--train <nonce>] [--dry-run]
|
||||
delete train/<nonce> branches of merged, superseded or dropped trains
|
||||
ready --pr <n> --screen [--broad] | --no-screen [--dry-run]
|
||||
(train policy) READY plus the screen choice, recorded as a receipt
|
||||
train watch --train <nonce> [--timeout <s>]
|
||||
resumable follow: runs the step the record asks for (build, status), waits for the
|
||||
pending run, reads the verdict, prints NEXT; rerun it after a restart (no daemon)
|
||||
train forget --train <nonce> | --receipt <pr>@<sha> [--dry-run]
|
||||
remove a settled train record (attempts, bisect) or a READY receipt from
|
||||
refs/notes/gstack-trains under an exact lease, then delete the local files
|
||||
train prune [--train <nonce>] [--dry-run] [--stale]
|
||||
delete train/<nonce> branches of merged, superseded or dropped trains; --stale also
|
||||
settles merging or green trains whose every PR is merged or closed on GitHub, and
|
||||
deletes screen/<pr>-* branches of merged or closed PRs
|
||||
ready --pr <n> --screen [--broad] | --no-screen [--dry-run] [--dispatch-ci <wf.yml[,…]> [--timeout <s>]]
|
||||
(train policy) READY plus the screen choice, recorded as a receipt; a head whose
|
||||
screen run is RED is refused (QUEUE_SCREEN_RED). --dispatch-ci runs the named
|
||||
workflows on the branch first (a conflicting PR has no pull_request CI), waits for
|
||||
them and clears the matching requires-remote pregate lanes with the run URLs
|
||||
verify --pr <n> --train <nonce> [--no-fetch]
|
||||
(train policy) the merge check against a green train record
|
||||
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run: SCREEN_VERDICT and
|
||||
FLAKY lines, recorded in the READY receipt
|
||||
verify --post-merge <sha> [--timeout <s>] after a merge: wait for every check run at the merged commit to settle;
|
||||
POST_MERGE: green|red|pending plus one CHECK: line per workflow (resumable)
|
||||
Merge trains need a "train" block in the base revision's ship policy (docs/ship-policy.md#run-a-merge-train).`;
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
@@ -80,14 +98,30 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
|
||||
if (Number.isNaN(freeTimeoutSec)) return usage('--free-timeout needs seconds');
|
||||
if (rest.includes('--urgent') && rest.includes('--repair')) return usage('--urgent and --repair are separate one-PR trains; pass one');
|
||||
if (rest.includes('--order') && (nonce || !flag(rest, '--order'))) return usage('--order <n>,... orders a new train from --prs; a resumed train keeps its recorded order');
|
||||
const stackCommand = flag(rest, '--stack-tests-command');
|
||||
if ((rest.includes('--stack-tests') || stackCommand !== undefined) && !dryRun) return usage('--stack-tests runs each PR\'s new test files on its integrated prefix; it needs --dry-run');
|
||||
const stackWords = stackCommand?.split(/\s+/).filter(Boolean);
|
||||
if (stackCommand !== undefined && !stackWords?.length) return usage('--stack-tests-command needs a command');
|
||||
return guarded('train build', () => buildTrain(ctx, {
|
||||
pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
|
||||
pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000,
|
||||
pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, stackTests: stackWords?.length ? stackWords : rest.includes('--stack-tests') ? true : undefined,
|
||||
}));
|
||||
}
|
||||
if (sub === 'status') return guarded('train status', () => trainStatus(ctx, { train: nonce }));
|
||||
if (sub === 'prune') return guarded('train prune', () => pruneTrains(ctx, { dryRun, train: nonce }));
|
||||
if (sub === 'prune') return guarded('train prune', () => pruneTrains(ctx, { dryRun, train: nonce, stale: rest.includes('--stale') }));
|
||||
if (sub === 'forget') {
|
||||
const receipt = flag(rest, '--receipt');
|
||||
if (!nonce && !receipt) return usage('train forget needs --train <nonce> or --receipt <pr>@<sha>');
|
||||
return guarded('train forget', () => trainForget(ctx, { train: nonce, receipt, dryRun }));
|
||||
}
|
||||
if (!nonce) return usage(`train ${sub ?? '<subcommand>'} needs --train <nonce>`);
|
||||
if (sub === 'watch') {
|
||||
const timeoutSec = positive(flag(rest, '--timeout'), 7200);
|
||||
if (Number.isNaN(timeoutSec)) return usage('--timeout needs seconds');
|
||||
const freeTimeoutSec = positive(flag(rest, '--free-timeout'), 2700);
|
||||
if (Number.isNaN(freeTimeoutSec)) return usage('--free-timeout needs seconds');
|
||||
return guarded('train watch', () => trainWatch(ctx, { train: nonce, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, freeTimeoutSec }));
|
||||
}
|
||||
if (sub === 'verdict') return guarded('train verdict', () => trainVerdict(ctx, { train: nonce }));
|
||||
if (sub === 'rebuild') return guarded('train rebuild', () => trainRebuild(ctx, { train: nonce, infra: rest.includes('--infra') }));
|
||||
if (sub === 'bisect') {
|
||||
@@ -103,9 +137,24 @@ export async function readyMain(ctx: QueueContext, args: string[], base: { pr: s
|
||||
const noScreen = args.includes('--no-screen');
|
||||
if (screen && noScreen) return usage('pass one of --screen or --no-screen');
|
||||
if (args.includes('--broad') && !screen) return usage('--broad selects the screen policy; it needs --screen');
|
||||
return guarded('ready', () => trainReady(ctx, { ...base, screen: screen ? 'screen' : noScreen ? 'no-screen' : undefined, broad: args.includes('--broad'), dryRun: args.includes('--dry-run') }));
|
||||
const dispatchCi = flag(args, '--dispatch-ci');
|
||||
const workflows = dispatchCi === undefined ? [] : dispatchCi.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-free-tests.yml)');
|
||||
const timeoutSec = positive(flag(args, '--timeout'), 2700);
|
||||
if (!Number.isFinite(timeoutSec)) return usage('--timeout needs a positive number of seconds');
|
||||
return guarded('ready', () => trainReady(ctx, { ...base, screen: screen ? 'screen' : noScreen ? 'no-screen' : undefined, broad: args.includes('--broad'), dryRun: args.includes('--dry-run'),
|
||||
dispatchCi: workflows.length ? { workflows, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000 } : undefined }));
|
||||
}
|
||||
|
||||
export async function postMergeMain(ctx: QueueContext, args: string[]): Promise<number> {
|
||||
const sha = flag(args, '--post-merge');
|
||||
if (!sha || !/^[0-9a-f]{40}$/.test(sha)) return usage('verify --post-merge needs the merged commit\'s full 40-hex SHA');
|
||||
const timeoutSec = positive(flag(args, '--timeout'), 2700);
|
||||
if (!Number.isFinite(timeoutSec)) return usage('--timeout needs a positive number of seconds');
|
||||
return guarded('verify', () => verifyPostMerge(ctx, { sha, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000 }));
|
||||
}
|
||||
|
||||
export async function verifyMain(ctx: QueueContext, args: string[], pr: string): Promise<number> {
|
||||
if (args.includes('--screen')) return guarded('verify', () => verifyScreen(ctx, { pr }));
|
||||
return guarded('verify', () => verifyTrain(ctx, { pr, train: flag(args, '--train')!, noFetch: args.includes('--no-fetch') }));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* `train forget --train <nonce> | --receipt <pr>@<head> [--dry-run]`: remove a
|
||||
* train record (its attempts and bisect ledger) or a READY receipt from
|
||||
* refs/notes/gstack-trains under an exact lease, then delete the local files
|
||||
* (until now a stale record needed a hand-run
|
||||
* `git notes remove` plus a file hunt on every machine that ran the train).
|
||||
* A rejected lease removes nothing (the local notes ref is restored too, so a
|
||||
* later write cannot carry the removal without a lease). Other machines that
|
||||
* still hold the local files can push the record back with their next write;
|
||||
* the output says so.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { outOk, outRefused, trainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { attemptsOf, forgetNotes, loadBisect, loadReady, loadTrain, noteName, readyPath, rebuildFromNotes, trainDir, type AnyRecord, type TrainState } from './ship-queue-train-record';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
/** States with work still in flight (a branch being built or pushed, free tests or a gate running, a merge under way): forget never settles them. */
|
||||
const LIVE_STATES: readonly TrainState[] = ['built', 'pushed', 'free-pending', 'free-green', 'dispatch-unknown', 'dispatch-absent', 'gate-pending', 'merging'];
|
||||
|
||||
export function trainForget(ctx: QueueContext, o: { train?: string; receipt?: string; dryRun: boolean }): TrainOut {
|
||||
const env = trainEnv(ctx);
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const records: AnyRecord[] = [];
|
||||
const files: string[] = [];
|
||||
let what = '';
|
||||
if (o.train) {
|
||||
const t = loadTrain(o.train);
|
||||
if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
|
||||
if (LIVE_STATES.includes(t.state)) {
|
||||
return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `train ${t.nonce} is ${t.state}; forget settles nothing that is still building, running or merging (train prune --stale for a train whose PRs merged elsewhere)` }, [], `${Q} train status --train ${t.nonce}`, trainDir(t.nonce));
|
||||
}
|
||||
const bisect = loadBisect(t.nonce);
|
||||
records.push(t, ...attemptsOf(t), ...(bisect ? [bisect] : []));
|
||||
files.push(trainDir(t.nonce));
|
||||
what = `train ${t.nonce} (${t.state}; ${t.attempts.length} attempt(s)${bisect ? ', a bisect ledger' : ''})`;
|
||||
} else {
|
||||
const m = /^#?(\d+)@([0-9a-f]{40,64})$/.exec(o.receipt ?? '');
|
||||
if (!m) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: '--receipt takes <pr>@<full head sha>' }, [], `${Q} train forget --receipt <pr>@<sha>`, 'none');
|
||||
const r = loadReady(env.repo.keys, m[1]!, m[2]!);
|
||||
if (!r) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no READY receipt for #${m[1]} at ${m[2]!.slice(0, 12)} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
|
||||
records.push(r);
|
||||
files.push(readyPath(r.repo_key, r.pr, r.head));
|
||||
what = `READY receipt #${r.pr} at ${r.head.slice(0, 12)} (screen ${r.screen.choice}${r.screen.run ? `, run ${r.screen.run}` : ''})`;
|
||||
}
|
||||
const names = records.map(noteName);
|
||||
const lines = [`FORGET: ${what}`, ...names.map(n => ` note ${n}`), ...files.map(f => ` local ${f}`)];
|
||||
if (o.dryRun) return outOk([...lines, 'DRY-RUN: nothing removed'], `${Q} train forget ${o.train ? `--train ${o.train}` : `--receipt ${o.receipt}`}`, 'none');
|
||||
const f = forgetNotes(ctx.repoRoot, names, env.remote);
|
||||
if (!f.pushed) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the notes ref was not updated (${f.detail}); nothing deleted locally` }, lines, `${Q} train forget ${o.train ? `--train ${o.train}` : `--receipt ${o.receipt}`} (the lease is re-read each run)`, 'none');
|
||||
for (const file of files) fs.rmSync(file, { recursive: true, force: true });
|
||||
lines.push(`FORGOTTEN: ${f.removed.length} note(s) removed${f.missing.length ? `, ${f.missing.length} already absent` : ''}; ${f.detail}; ${files.length} local path(s) deleted`,
|
||||
'note: a machine that still holds these files locally can push the record back with its next write; run train forget there too, or clear its $GSTACK_STATE_ROOT/ship-queue copy');
|
||||
return outOk(lines, `${Q} train status`, 'none');
|
||||
}
|
||||
@@ -57,14 +57,21 @@ export function asRun(summary: RunSummary): { id: number; url: string } {
|
||||
return { id: summary.databaseId, url: summary.url };
|
||||
}
|
||||
|
||||
/** One JSON artifact of a run (the named file, else the first .json file it holds); null when it is missing or unreadable. */
|
||||
/**
|
||||
* One JSON artifact of a run: the named file wherever the artifact keeps it
|
||||
* (a multi-path upload keeps its directories, so report-verdict-a1 holds
|
||||
* paid-report/collector-outcomes.json), else the first .json file at its
|
||||
* root; null when it is missing or unreadable.
|
||||
*/
|
||||
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string, file?: string): unknown {
|
||||
const dir = path.join(scratch, `${run}-${name}`);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
try {
|
||||
if (!ctx.gh(['run', 'download', String(run), '--repo', slug, '-n', name, '-D', dir]).ok) return null;
|
||||
const pick = file ?? fs.readdirSync(dir).find(f => f.endsWith('.json'));
|
||||
const pick = file
|
||||
? (fs.readdirSync(dir, { recursive: true }) as string[]).filter(f => path.basename(f) === file).sort((a, b) => a.length - b.length)[0]
|
||||
: fs.readdirSync(dir).find(f => f.endsWith('.json'));
|
||||
return pick && fs.existsSync(path.join(dir, pick)) ? JSON.parse(fs.readFileSync(path.join(dir, pick), 'utf8')) : null;
|
||||
} catch { return null; } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
+138
-27
@@ -2,13 +2,21 @@
|
||||
* `ready --pr <n> --screen | --no-screen` under a ship policy with a `train`
|
||||
* block (docs/ship-policy.md#run-a-merge-train; plan GSTA-49 T1). The
|
||||
* READY block is today's (lib/ship-queue.ts ready()); this adds the screen
|
||||
* selection and Garry's default recommendation (screen when the screen
|
||||
* selection and the default recommendation (screen when the screen
|
||||
* selects a skill's own cases), requires an explicit choice
|
||||
* (READY_SCREEN_CHOICE_REQUIRED), dispatches a chosen screen through
|
||||
* eval-bg's nonce with its purpose and policy in the key, and records the
|
||||
* choice as a READY receipt (local state and refs/notes/gstack-trains) that
|
||||
* `train build` requires. The screen's dollar price is left to the T0 cost
|
||||
* replay (lib/gate-cost.ts) and printed as pending until it lands.
|
||||
*
|
||||
* The screen's verdict comes from its run (lib/ship-queue-screen.ts): a head
|
||||
* whose recorded screen is RED is refused (QUEUE_SCREEN_RED) under either
|
||||
* choice, and a red run is never re-dispatched at its merge SHA. The screen
|
||||
* is planned on GitHub's refs/pull/<n>/merge when that merges the head, else
|
||||
* on a scratch merge of the head onto origin/<base> built with the train's
|
||||
* merge rules and pushed as screen/<pr>-<head12> (#3465); only a real
|
||||
* conflict is QUEUE_SCREEN_STALE_REF.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg';
|
||||
@@ -16,13 +24,19 @@ import { EXIT } from './headless-artifacts';
|
||||
import { ready, type QueueContext } from './ship-queue';
|
||||
import { classifyTrainHead } from './version-gate-train';
|
||||
import { git } from './ship-queue-gate';
|
||||
import { execFor, hollowScreen, repoSlug } from './ship-queue-train-github';
|
||||
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
|
||||
import { outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { dispatchCi } from './ship-queue-dispatch-ci';
|
||||
import { recordVerdict, screenVerdict, verdictLines, type ScreenOutcome } from './ship-queue-screen';
|
||||
import { execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { loadReady, nowIso, persist, readyPath, rebuildFromNotes, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
|
||||
import { fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
|
||||
export interface ReadyOptions { pr: string; pregate?: string; gateExit?: number; gateLog?: string; screen?: 'screen' | 'no-screen'; broad: boolean; dryRun: boolean }
|
||||
export interface ReadyOptions {
|
||||
pr: string; pregate?: string; gateExit?: number; gateLog?: string; screen?: 'screen' | 'no-screen'; broad: boolean; dryRun: boolean;
|
||||
/** `--dispatch-ci`: run these workflows on the PR branch, wait, and clear the matching requires-remote pregate lanes before reading the head. */
|
||||
dispatchCi?: { workflows: string[]; timeoutSec: number; pollMs: number };
|
||||
}
|
||||
export interface ScreenSelection { lines: string[]; recommend: 'screen' | 'no-screen' | null }
|
||||
|
||||
/** Skill directories a diff touches: top-level directories that hold a SKILL.md.tmpl at the head. */
|
||||
@@ -70,8 +84,46 @@ async function screenSelection(env: TrainEnv, head: string, baseSha: string): Pr
|
||||
return { ...s, lines: [...s.lines, price] };
|
||||
}
|
||||
|
||||
/** The scratch branch a screen's merge commit is pushed to when GitHub's merge ref lags the head. */
|
||||
export const screenBranch = (pr: string, head: string) => `screen/${pr}-${head.slice(0, 12)}`;
|
||||
|
||||
/**
|
||||
* The merge commit the screen tests: GitHub's refs/pull/<n>/merge when it
|
||||
* merges the PR head, else a scratch merge of the head onto origin/<base>
|
||||
* (the train's merge rules) pushed as screen/<pr>-<head12>, so evals.yml can
|
||||
* check it out and read its parents. A conflict beyond the CHANGELOG and the
|
||||
* stamp files is the only QUEUE_SCREEN_STALE_REF left.
|
||||
*/
|
||||
function screenMerge(env: TrainEnv, o: ReadyOptions, head: string, baseSha: string): { mergeSha: string; branch?: string; line: string } | TrainOut {
|
||||
const { ctx } = env;
|
||||
const ls = git(ctx.repoRoot, ['ls-remote', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
if (!ls.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not list ${env.remote}'s refs: ${(ls.err || ls.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git ls-remote failed'}` }, [], `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const github = ls.out.split(/\s/)[0] ?? '';
|
||||
let lag = `${env.remote} has no refs/pull/${o.pr}/merge`;
|
||||
if (/^[0-9a-f]{40,64}$/.test(github)) {
|
||||
const fetched = git(ctx.repoRoot, ['fetch', '-q', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
const parent = fetched.ok ? git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${github}^2`]).out : '';
|
||||
const onto = fetched.ok ? git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${github}^1`]).out : '';
|
||||
if (parent === head && onto === baseSha) return { mergeSha: github, line: `SCREEN_MERGE: GitHub's refs/pull/${o.pr}/merge ${github.slice(0, 12)} merges the head ${head.slice(0, 12)} onto ${ctx.base}@${baseSha.slice(0, 12)}` };
|
||||
lag = !fetched.ok ? `refs/pull/${o.pr}/merge ${github.slice(0, 12)} could not be fetched`
|
||||
: parent !== head ? `refs/pull/${o.pr}/merge ${github.slice(0, 12)} merges ${parent ? parent.slice(0, 12) : 'no PR head'}, not ${head.slice(0, 12)}`
|
||||
: `refs/pull/${o.pr}/merge ${github.slice(0, 12)} merges onto ${onto ? onto.slice(0, 12) : 'no base'}, not ${ctx.base}@${baseSha.slice(0, 12)}`;
|
||||
}
|
||||
const m = mergeCommit(ctx.repoRoot, baseSha, head, env.policy.policy, `screen #${o.pr}: merge ${head.slice(0, 12)} onto ${ctx.base} ${baseSha.slice(0, 12)}`);
|
||||
if ('conflicts' in m) {
|
||||
return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `#${o.pr} conflicts with ${ctx.base}@${baseSha.slice(0, 12)} beyond the CHANGELOG and the stamp files (${m.conflicts.join(', ')}); GitHub's merge ref lags too (${lag})` }, [],
|
||||
`resolve the conflict with ${ctx.base}, push, run /ship, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
}
|
||||
const branch = screenBranch(o.pr, head);
|
||||
const line = `SCREEN_MERGE: scratch merge ${m.commit.slice(0, 12)} of #${o.pr}@${head.slice(0, 12)} onto ${ctx.base}@${baseSha.slice(0, 12)} (${lag})`;
|
||||
if (o.dryRun) return { mergeSha: m.commit, branch, line: `${line}; would push ${branch} (dry run)` };
|
||||
const p = git(ctx.repoRoot, ['push', '-q', '-f', env.remote, `${m.commit}:refs/heads/${branch}`]);
|
||||
if (!p.ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `could not push the screen's scratch merge as ${branch}: ${p.err.trim().replace(/\s+/g, ' ').slice(0, 200)}` }, [line], `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
return { mergeSha: m.commit, branch, line: `${line}; pushed ${branch}` };
|
||||
}
|
||||
|
||||
/** Dispatch (or adopt) the PR's screen on the base branch: inputs name the PR, its merge SHA and the screen policy. */
|
||||
async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string, baseSha: string): Promise<{ choice: ScreenChoice; line: string } | TrainOut> {
|
||||
async function dispatchScreen(env: TrainEnv, o: ReadyOptions, slug: string | null, mergeSha: string, baseSha: string): Promise<{ choice: ScreenChoice; lines: string[] } | TrainOut> {
|
||||
const policy = o.broad ? 'broad' : 'narrow';
|
||||
const scope = { purpose: 'screen' as const, policy: policy as 'narrow' | 'broad' };
|
||||
const nonce = makeNonce('pr', mergeSha, baseSha, randomBytes(3).toString('hex'), scope);
|
||||
@@ -80,23 +132,45 @@ async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string,
|
||||
[DISPATCH_INPUTS.baseSha]: baseSha, [DISPATCH_INPUTS.screenPr]: o.pr, [DISPATCH_INPUTS.screenMergeSha]: mergeSha, [DISPATCH_INPUTS.screenPolicy]: policy,
|
||||
} };
|
||||
const inputs = Object.entries(dispatch.inputs).map(([k, v]) => `${k}=${v}`).join(' ');
|
||||
if (o.dryRun) return { choice: { choice: 'screen', policy, nonce, run: null, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} --ref ${env.ctx.base} ${inputs} (dry run: not dispatched)` };
|
||||
const slug = repoSlug(env.ctx);
|
||||
if (o.dryRun) return { choice: { choice: 'screen', policy, nonce, run: null, merge_sha: mergeSha }, lines: [`SCREEN_DISPATCH: ${dispatch.workflow} --ref ${env.ctx.base} ${inputs} (dry run: not dispatched)`] };
|
||||
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const exec = execFor(env.ctx);
|
||||
const existing = findDuplicateRun(listDispatchRuns(dispatch.workflow, exec, slug) ?? [], dedupeKey('pr', mergeSha, baseSha, scope));
|
||||
const plan = existing ? hollowScreen(env.ctx, slug, existing.databaseId) : null;
|
||||
const hollow = plan?.kind === 'hollow' ? plan.reason : null;
|
||||
if (existing && !hollow) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
|
||||
const prior: ScreenOutcome | null = existing ? screenVerdict(env.ctx, slug, existing.databaseId) : null;
|
||||
if (existing && prior) {
|
||||
const choice: ScreenChoice = { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha };
|
||||
recordVerdict(choice, prior);
|
||||
const lines = verdictLines(o.pr, existing.databaseId, prior);
|
||||
if (prior.kind === 'red') return outRefused({ code: 'QUEUE_SCREEN_RED', message: `the screen of #${o.pr} at merge ${mergeSha.slice(0, 12)} (run ${existing.databaseId}) is RED on ${prior.red.join(', ') || 'its gate binding'}; a red screen is not re-dispatched at the same merge SHA` },
|
||||
lines, `repair, push a new head, run /ship, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
if (prior.kind !== 'not-run' && prior.kind !== 'incomplete') {
|
||||
return { choice, lines: [...lines, `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)`] };
|
||||
}
|
||||
}
|
||||
const notFollowing = existing && prior ? ` (not following ${prior.detail}: a screen that ${prior.kind === 'not-run' ? 'planned nothing' : 'bound no verdict'} is not a screen)` : '';
|
||||
const found = dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null;
|
||||
if (!found) return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${dispatch.workflow} run named ${nonce} appeared after the screen dispatch` }, [], `${Q} ready --pr ${o.pr} --screen${o.broad ? ' --broad' : ''} (adopts the run by its key once it appears)`, 'none');
|
||||
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}${hollow ? ` (not following ${hollow}: a screen that planned nothing is not a screen)` : ''}` };
|
||||
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, lines: [`SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}${notFollowing}`] };
|
||||
}
|
||||
|
||||
const screenLine = (c: ScreenChoice) => `screen: ${c.choice}${c.policy ? ` policy=${c.policy} nonce=${c.nonce} run=${c.run ?? '-'}${c.verdict ? ` verdict=${c.verdict.outcome}` : ''}` : ''}`;
|
||||
|
||||
export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<TrainOut> {
|
||||
const fetchErr = fetchBase(ctx);
|
||||
const env = trainEnv(ctx);
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
if (fetchErr) return outRefused({ code: 'POLICY_SOURCE_UNAVAILABLE', message: fetchErr }, [], 'none', 'none');
|
||||
const ciLines: string[] = [];
|
||||
if (o.dispatchCi && !o.dryRun) {
|
||||
const d = await dispatchCi(ctx, { pr: o.pr, pregate: o.pregate, ...o.dispatchCi });
|
||||
ciLines.push(...d.lines);
|
||||
if ('failure' in d) {
|
||||
const again = `${Q} ready --pr ${o.pr} ${o.screen === 'screen' ? '--screen' : '--no-screen'} --dispatch-ci ${o.dispatchCi.workflows.join(',')} --timeout ${o.dispatchCi.timeoutSec}`;
|
||||
return (d.failure.code === 'QUEUE_TRAIN_PENDING' ? outPending : outRefused)(d.failure, ciLines, d.failure.code === 'QUEUE_TRAIN_PENDING' ? again : `read the run, repair, push, run /ship, then ${again}`, 'none');
|
||||
}
|
||||
}
|
||||
const base = ready(ctx, o);
|
||||
base.lines.unshift(...ciLines);
|
||||
if (!base.ok) {
|
||||
const out = outRefused(base.failure!, base.lines, `fix the refusal, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, base.run);
|
||||
return ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(base.failure!.code) ? { ...out, exit: EXIT.fail } : out;
|
||||
@@ -107,6 +181,18 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
if (kind.kind === 'invalid') return outRefused({ code: 'QUEUE_NOT_READY', message: `the Version Gate refuses ${head.slice(0, 12)}: ${kind.reason}` }, base.lines, `fix the head, push, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
|
||||
const sel = await screenSelection(env, head, baseSha);
|
||||
const lines = [...base.lines, ...sel.lines];
|
||||
if (!loadReady(env.repo.keys, o.pr, head)) rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const prior = loadReady(env.repo.keys, o.pr, head);
|
||||
const slug = repoSlug(ctx);
|
||||
if (prior?.screen.run && slug) {
|
||||
const read = screenVerdict(ctx, slug, prior.screen.run);
|
||||
lines.push(...verdictLines(o.pr, prior.screen.run, read));
|
||||
if (recordVerdict(prior.screen, read) && !o.dryRun) { prior.updated_at = nowIso(); persist(ctx.repoRoot, [prior], { remote: env.remote }); }
|
||||
if (read.kind === 'red') {
|
||||
return outRefused({ code: 'QUEUE_SCREEN_RED', message: `the screen recorded for ${head.slice(0, 12)} (run ${prior.screen.run}) is RED on ${read.red.join(', ') || 'its gate binding'}; this head is not READY under any screen choice` },
|
||||
lines, `repair, push a new head, run /ship, then ${Q} ready --pr ${o.pr} --screen`, readyPath(env.repo.key, o.pr, head));
|
||||
}
|
||||
}
|
||||
const rec = sel.recommend ?? 'no-screen';
|
||||
if (!o.screen) {
|
||||
return outRefused({ code: 'READY_SCREEN_CHOICE_REQUIRED', message: `the base policy runs merge trains; choose --screen or --no-screen (recommended: --${rec})` }, lines,
|
||||
@@ -114,26 +200,51 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
}
|
||||
let choice: ScreenChoice = { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null };
|
||||
if (o.screen === 'screen') {
|
||||
const ls = git(ctx.repoRoot, ['ls-remote', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
if (!ls.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not list ${env.remote}'s refs: ${(ls.err || ls.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git ls-remote failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const mergeSha = ls.out.split(/\s/)[0] ?? '';
|
||||
const again = `resolve the conflict with ${ctx.base} (or wait for GitHub to recompute refs/pull/${o.pr}/merge), push, then ${Q} ready --pr ${o.pr} --screen; a screen dispatched on a stale merge ref plans no cases`;
|
||||
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, again, 'none');
|
||||
const fetched = git(ctx.repoRoot, ['fetch', '-q', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
if (!fetched.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not fetch refs/pull/${o.pr}/merge from ${env.remote}: ${(fetched.err || fetched.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git fetch failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const mergeParent = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${mergeSha}^2`]).out;
|
||||
if (mergeParent !== head) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote}'s refs/pull/${o.pr}/merge (${mergeSha.slice(0, 12)}) merges ${mergeParent ? mergeParent.slice(0, 12) : 'no PR head'}, not the PR head ${head.slice(0, 12)}: GitHub has not recomputed it, which is what a conflicting PR looks like (CHANGELOG-only conflicts included)` }, lines, again, 'none');
|
||||
const d = await dispatchScreen(env, o, mergeSha, baseSha);
|
||||
const merge = screenMerge(env, o, head, baseSha);
|
||||
if ('exit' in merge) return { ...merge, lines: [...lines, ...merge.lines] };
|
||||
lines.push(merge.line);
|
||||
const d = await dispatchScreen(env, o, slug, merge.mergeSha, baseSha);
|
||||
if ('exit' in d) return { ...d, lines: [...lines, ...d.lines] };
|
||||
choice = d.choice;
|
||||
lines.push(d.line);
|
||||
choice = merge.branch ? { ...d.choice, branch: merge.branch } : d.choice;
|
||||
lines.push(...d.lines);
|
||||
}
|
||||
lines.splice(1, 0, `screen: ${choice.choice}${choice.policy ? ` policy=${choice.policy} nonce=${choice.nonce} run=${choice.run ?? '-'}` : ''}`);
|
||||
lines.splice(lines.findIndex(l => l.startsWith('READY ')) + 1, 0, screenLine(choice));
|
||||
const now = nowIso();
|
||||
const readyAt = loadReady(env.repo.keys, o.pr, head)?.ready_at ?? now;
|
||||
const receipt: ReadyReceipt = { schema: 1, kind: 'ready', repo_key: env.repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: readyAt, screen: choice, updated_at: now };
|
||||
const receipt: ReadyReceipt = { schema: 1, kind: 'ready', repo_key: env.repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: prior?.ready_at ?? now, screen: choice, updated_at: now };
|
||||
if (o.dryRun) return outOk([...lines, 'DRY-RUN: no READY receipt written, nothing dispatched'], `${Q} ready --pr ${o.pr} --${o.screen}${o.broad ? ' --broad' : ''}`, 'none');
|
||||
const p = persist(ctx.repoRoot, [receipt], { remote: env.remote });
|
||||
lines.push(`READY_RECEIPT: ${readyPath(env.repo.key, o.pr, head)} (${p.detail})`);
|
||||
return outOk(lines, `send the READY block; the coordinator builds the train: ${Q} train build --prs ${o.pr}@${head},...`, readyPath(env.repo.key, o.pr, head));
|
||||
}
|
||||
|
||||
/**
|
||||
* `verify --pr <n> --screen`: the screen verdict of the PR's pushed head from
|
||||
* its run, recorded in the READY receipt; never a lane's prose.
|
||||
*/
|
||||
export function verifyScreen(ctx: QueueContext, o: { pr: string }): TrainOut {
|
||||
const env = trainEnv(ctx);
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid']);
|
||||
let head = '';
|
||||
try { head = view.ok ? String(JSON.parse(view.out).headRefOid ?? '') : ''; } catch {}
|
||||
if (!/^[0-9a-f]{40,64}$/.test(head)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `cannot read PR #${o.pr}'s head through gh (${(view.err ?? view.out).trim().slice(0, 120) || 'no headRefOid'})` }, [], `${Q} verify --pr ${o.pr} --screen`, 'none');
|
||||
if (!loadReady(env.repo.keys, o.pr, head)) rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const receipt = loadReady(env.repo.keys, o.pr, head);
|
||||
const run = receipt ? readyPath(receipt.repo_key, o.pr, head) : 'none';
|
||||
if (!receipt) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${o.pr} at ${head.slice(0, 12)} (local state or refs/notes/gstack-trains)` }, [], `the lane runs ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
|
||||
const lines = [`READY: #${o.pr} ${head} ready_at=${receipt.ready_at}`, screenLine(receipt.screen)];
|
||||
if (receipt.screen.choice !== 'screen' || !receipt.screen.run) return outOk([...lines, `SCREEN_VERDICT: #${o.pr} none (${receipt.screen.choice === 'screen' ? 'no run recorded' : 'READY --no-screen'})`], `${Q} train build --prs ${o.pr}@${head},...`, run);
|
||||
const slug = repoSlug(ctx);
|
||||
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, lines, `${Q} verify --pr ${o.pr} --screen`, run);
|
||||
const read = screenVerdict(ctx, slug, receipt.screen.run);
|
||||
lines.push(...verdictLines(o.pr, receipt.screen.run, read));
|
||||
if (recordVerdict(receipt.screen, read)) { receipt.updated_at = nowIso(); lines.push(`READY_RECEIPT: ${run} (${persist(ctx.repoRoot, [receipt], { remote: env.remote }).detail}; verdict recorded)`); }
|
||||
const again = `${Q} verify --pr ${o.pr} --screen`;
|
||||
switch (read.kind) {
|
||||
case 'green': return outOk(lines, `${Q} train build --prs ${o.pr}@${head},...`, run);
|
||||
case 'pending': return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `the screen run ${receipt.screen.run} has not finished (${read.detail})` }, lines, again, run);
|
||||
case 'red': return { ...outRefused({ code: 'QUEUE_SCREEN_RED', message: `the screen of #${o.pr} at ${head.slice(0, 12)} (run ${receipt.screen.run}) is RED on ${read.red.join(', ') || 'its gate binding'}` }, lines, `repair, push a new head, run /ship, then ${Q} ready --pr ${o.pr} --screen`, run), exit: EXIT.fail };
|
||||
case 'not-run': return outRefused({ code: 'QUEUE_SCREEN_NOT_RUN', message: `the screen of #${o.pr} (run ${receipt.screen.run}) concluded success with no slice: ${read.detail}; it is not a screen, and this head is not screened` }, lines, `${Q} ready --pr ${o.pr} --screen (dispatches a fresh screen)`, run);
|
||||
default: return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the screen of #${o.pr} (run ${receipt.screen.run}) has no verdict to read: ${read.detail}` }, lines, read.kind === 'incomplete' ? `${Q} ready --pr ${o.pr} --screen (dispatches a fresh screen)` : again, run);
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,18 @@ const NOTES_REMOTE_REF = 'refs/notes/gstack-trains-origin';
|
||||
|
||||
export type TrainState =
|
||||
| 'built' | 'pushed' | 'free-pending' | 'free-red' | 'free-green' | 'dispatch-unknown' | 'dispatch-absent' | 'gate-pending'
|
||||
| 'green' | 'red' | 'incomplete' | 'attributed' | 'red-on-main' | 'unattributed' | 'merging' | 'merged' | 'superseded';
|
||||
/** States after which a train never dispatches again; prune deletes their branches. */
|
||||
export const TERMINAL_STATES: readonly TrainState[] = ['free-red', 'attributed', 'red-on-main', 'merged', 'superseded'];
|
||||
| 'green' | 'red' | 'incomplete' | 'attributed' | 'red-on-main' | 'unattributed' | 'merging' | 'merged' | 'superseded' | 'stale';
|
||||
/** States after which a train never dispatches again; prune deletes their branches (`stale`: every PR merged or closed outside the train, `train prune --stale`). */
|
||||
export const TERMINAL_STATES: readonly TrainState[] = ['free-red', 'attributed', 'red-on-main', 'merged', 'superseded', 'stale'];
|
||||
|
||||
export interface ScreenChoice { choice: 'screen' | 'no-screen'; policy: 'narrow' | 'broad' | null; nonce: string | null; run: number | null; merge_sha: string | null }
|
||||
/** What the screen run itself said (lib/ship-queue-screen.ts), recorded when it was read; absent while the run is pending or unread. */
|
||||
export interface ScreenVerdictRecord { outcome: 'GREEN' | 'RED' | 'NOT_RUN' | 'INCOMPLETE'; run: number; red: string[]; flaky: string[]; detail: string; read_at: string }
|
||||
export interface ScreenChoice {
|
||||
choice: 'screen' | 'no-screen'; policy: 'narrow' | 'broad' | null; nonce: string | null; run: number | null; merge_sha: string | null;
|
||||
/** The scratch branch `ready --screen` pushed when GitHub's merge ref lagged the head (docs/ship-policy.md#run-a-merge-train). */
|
||||
branch?: string;
|
||||
verdict?: ScreenVerdictRecord;
|
||||
}
|
||||
export interface TrainSlot { pr: string; head: string; ready_at: string; screen: ScreenChoice }
|
||||
export interface TrainPrefix { index: number; commit: string; tree: string; pr: string | null }
|
||||
export interface FreeRun { run: number | null; url: string | null; head_sha: string | null; conclusion: string | null; verdict: 'green' | 'red' | 'missing' | 'stale' | 'cancelled' | 'pending' }
|
||||
@@ -154,7 +161,7 @@ export function recordPath(r: AnyRecord): string {
|
||||
return readyPath(r.repo_key, r.pr, r.head);
|
||||
}
|
||||
|
||||
function noteName(r: AnyRecord): string {
|
||||
export function noteName(r: AnyRecord): string {
|
||||
if (r.kind === 'train') return `gstack-trains:train:${r.nonce}`;
|
||||
if (r.kind === 'train-attempt') return `gstack-trains:attempt:${r.train}:${r.id}`;
|
||||
if (r.kind === 'train-bisect') return `gstack-trains:bisect:${r.train}`;
|
||||
@@ -227,6 +234,35 @@ function parseNote(text: string): AnyRecord | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove records from the notes ref (`train forget`): each named note is
|
||||
* removed locally, then the ref is pushed under an exact lease on the remote
|
||||
* tip this call fetched, so a concurrent writer's push is never overwritten
|
||||
* (a rejected lease returns `pushed: false` and nothing is deleted locally).
|
||||
* Names are note names (noteName); a name with no note is reported missing.
|
||||
*/
|
||||
export function forgetNotes(repoRoot: string, names: string[], remote = 'origin'): { removed: string[]; missing: string[]; failed: string[]; pushed: boolean; lease: string | null; detail: string } {
|
||||
const f = fetchNotes(repoRoot, remote);
|
||||
if (!f.ok) return { removed: [], missing: [], failed: [], pushed: false, lease: null, detail: f.detail };
|
||||
const lease = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REMOTE_REF]).out || null;
|
||||
const before = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REF]).out || null;
|
||||
const listed = git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'list']).out.split('\n').map(l => l.split(' ')[1]).filter(Boolean);
|
||||
const removed: string[] = [];
|
||||
const missing: string[] = [];
|
||||
const failed: string[] = [];
|
||||
const restore = () => { if (before) git(repoRoot, ['update-ref', NOTES_REF, before]); };
|
||||
for (const name of names) {
|
||||
const blob = git(repoRoot, ['hash-object', '--stdin'], undefined, name).out;
|
||||
if (!listed.includes(blob)) { missing.push(name); continue; }
|
||||
if (git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'remove', blob], notesEnv()).ok) removed.push(name); else failed.push(name);
|
||||
}
|
||||
if (failed.length) { restore(); return { removed: [], missing, failed, pushed: false, lease, detail: `git notes remove failed for ${failed.join(', ')}; the local notes ref was restored` }; }
|
||||
if (!removed.length) return { removed, missing, failed, pushed: false, lease, detail: 'no note to remove' };
|
||||
const p = git(repoRoot, ['push', '-q', lease ? `--force-with-lease=${NOTES_REF}:${lease}` : `--force-with-lease=${NOTES_REF}`, remote, `${NOTES_REF}:${NOTES_REF}`]);
|
||||
if (!p.ok) restore();
|
||||
return { removed, missing, failed, pushed: p.ok, lease, detail: p.ok ? `notes pushed to ${remote} ${NOTES_REF} (lease ${lease?.slice(0, 12) ?? 'new ref'})` : `git push ${NOTES_REF} rejected: ${p.err.replace(/\s+/g, ' ').trim().slice(0, 160)}; the local notes ref was restored` };
|
||||
}
|
||||
|
||||
/** Rebuild local records from the notes ref: a note whose record is missing locally, or newer than the local copy, is written. */
|
||||
export function rebuildFromNotes(repoRoot: string, remote = 'origin'): { restored: number; read: number; detail: string } {
|
||||
const f = fetchNotes(repoRoot, remote);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `train watch --train <nonce> [--timeout <s>]`: the controller's one
|
||||
* resumable follow command. Machines restart and kill
|
||||
* background `gh run watch` chains, so watch keeps no process state: it
|
||||
* reads the train record, runs the step the state asks for (train build
|
||||
* --train to push, run the free tests and dispatch the gate; train status to
|
||||
* settle an unconfirmed dispatch), waits for the pending run to finish by
|
||||
* polling gh, then reads the verdict, and prints the last step's NEXT. Run it
|
||||
* again after an interruption and it continues from the record. It never
|
||||
* dispatches anything the record would not dispatch on its own.
|
||||
*/
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { buildTrain, findTrain, nextFor, outOk, outPending, outRefused, trainEnv, trainStatus, type TrainOut } from './ship-queue-train';
|
||||
import { repoSlug, runView } from './ship-queue-train-github';
|
||||
import { attemptsOf, trainFile, type TrainRecord } from './ship-queue-train-record';
|
||||
import { trainVerdict } from './ship-queue-train-verdict';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export interface WatchOptions { train: string; timeoutSec: number; pollMs: number; freeTimeoutSec: number }
|
||||
|
||||
/** Wait for the train's current gate run to complete; null when it did, else why the wait stopped. */
|
||||
async function awaitGate(ctx: QueueContext, t: TrainRecord, deadline: number, pollMs: number): Promise<string | null> {
|
||||
const a = attemptsOf(t).filter(x => x.purpose !== 'bisect').at(-1);
|
||||
if (!a?.run) return `no gate run is recorded for ${t.nonce}`;
|
||||
const slug = repoSlug(ctx);
|
||||
if (!slug) return 'gh repo view could not name the repository';
|
||||
for (;;) {
|
||||
const view = runView(ctx, slug, a.run.id);
|
||||
if (view?.status === 'completed') return null;
|
||||
if (Date.now() >= deadline) return `run ${a.run.id} is still ${view?.status ?? 'unreadable'} at the watch timeout`;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function trainWatch(ctx: QueueContext, o: WatchOptions): Promise<TrainOut> {
|
||||
const env = trainEnv(ctx);
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
let t = findTrain(env, o.train);
|
||||
if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
|
||||
const run = trainFile(t.nonce);
|
||||
const deadline = Date.now() + o.timeoutSec * 1000;
|
||||
const lines: string[] = [`WATCH: train ${t.nonce} state=${t.state} (resumable; rerun this command after an interruption)`];
|
||||
const again = `${Q} train watch --train ${t.nonce} --timeout ${o.timeoutSec}`;
|
||||
const step = (label: string, out: TrainOut): TrainOut => ({ ...out, lines: [...lines, `WATCH: ${label}`, ...out.lines] });
|
||||
const reload = () => { t = findTrain(env, o.train) ?? t; };
|
||||
if (t.state === 'dispatch-unknown') {
|
||||
const s = trainStatus(ctx, { train: t.nonce });
|
||||
lines.push(`WATCH: train status settled the unconfirmed dispatch`, ...s.lines.filter(l => l.startsWith('ATTEMPT:')));
|
||||
reload();
|
||||
}
|
||||
if (['built', 'pushed', 'free-pending', 'free-green', 'dispatch-absent'].includes(t.state)) {
|
||||
const remaining = Math.max(1, Math.floor((deadline - Date.now()) / 1000));
|
||||
const b = await buildTrain(ctx, { resume: t.nonce, dryRun: false, urgent: false, repair: false, freeTimeoutSec: Math.min(o.freeTimeoutSec, remaining), pollMs: o.pollMs });
|
||||
reload();
|
||||
if (b.status !== 'complete') return step(`train build --train ${t.nonce} stopped (${b.failure?.code ?? b.status})`, b.failure?.code === 'QUEUE_TRAIN_PENDING' ? { ...b, next: again } : b);
|
||||
lines.push(`WATCH: train build --train ${t.nonce}: free tests green, gate dispatched`);
|
||||
}
|
||||
if (t.state === 'gate-pending') {
|
||||
const why = await awaitGate(ctx, t, deadline, o.pollMs);
|
||||
if (why) return outPending({ code: 'QUEUE_TRAIN_PENDING', message: why }, lines, again, run);
|
||||
lines.push('WATCH: gate run completed; reading the verdict');
|
||||
return step(`train verdict --train ${t.nonce}`, trainVerdict(ctx, { train: t.nonce }));
|
||||
}
|
||||
if (t.state === 'dispatch-unknown') return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `the gate dispatch for ${t.nonce} is still unconfirmed` }, lines, again, run);
|
||||
return outOk([...lines, `WATCH: nothing to wait for in state ${t.state}`], nextFor(t), run);
|
||||
}
|
||||
+99
-22
@@ -23,7 +23,9 @@ import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
|
||||
import { loadBasePolicy, type QueueContext } from './ship-queue';
|
||||
import { bindReceipt } from './ship-receipt';
|
||||
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
|
||||
import { asRun, execFor, hollowScreen, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
|
||||
import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen';
|
||||
import { stackTestLine, stackTests } from './ship-queue-stack-tests';
|
||||
import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
|
||||
import {
|
||||
TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile,
|
||||
type AttemptPurpose, type AttemptRecord, type TrainRecord, type TrainSlot,
|
||||
@@ -51,7 +53,7 @@ export function trainEnv(ctx: QueueContext): TrainEnv | QueueFailure {
|
||||
export function fetchBase(ctx: QueueContext): string | null {
|
||||
const remote = ctx.remote ?? 'origin';
|
||||
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', remote, `+refs/heads/${ctx.base}:refs/remotes/${remote}/${ctx.base}`]);
|
||||
return f.ok ? null : `git fetch ${remote} ${ctx.base} failed: ${f.err.slice(0, 160)}`;
|
||||
return f.ok ? null : `git fetch ${remote} ${ctx.base} failed: ${f.err.replace(/\s+/g, ' ').trim().slice(0, 160)}`;
|
||||
}
|
||||
|
||||
/** Make the train's prefix commits local (another machine built them): fetch train/<nonce>. */
|
||||
@@ -195,17 +197,28 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
|
||||
const TRAIN_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue',
|
||||
GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' };
|
||||
|
||||
/**
|
||||
* One train merge as a commit with a fixed identity and date: parents are the
|
||||
* predecessor (first, as GitHub's merge ref) and the PR head. Prefix commits
|
||||
* and the scratch merge a screen is planned on (`ready --screen` when
|
||||
* GitHub's refs/pull/<n>/merge lags the head) are both built here.
|
||||
*/
|
||||
export function mergeCommit(repoRoot: string, pred: string, head: string, policy: ShipPolicy, message: string): { commit: string; tree: string } | { conflicts: string[] } {
|
||||
const m = mergeStep(repoRoot, pred, head, policy);
|
||||
if ('conflicts' in m) return m;
|
||||
const c = git(repoRoot, ['commit-tree', m.tree, '-p', pred, '-p', head, '-m', message], { ...process.env, ...TRAIN_IDENTITY });
|
||||
return c.ok ? { commit: c.out, tree: m.tree } : { conflicts: [`(commit-tree failed: ${c.err.slice(0, 120)})`] };
|
||||
}
|
||||
|
||||
/** Build T1..Tk on T0 = base; a conflict stops at the PR that conflicts. */
|
||||
export function buildChain(repoRoot: string, baseSha: string, slots: TrainSlot[], policy: ShipPolicy):
|
||||
{ prefixes: TrainRecord['prefixes'] } | { stoppedAt: string; conflicts: string[]; merged: string[] } {
|
||||
const prefixes: TrainRecord['prefixes'] = [{ index: 0, commit: baseSha, tree: git(repoRoot, ['rev-parse', `${baseSha}^{tree}`]).out, pr: null }];
|
||||
for (const [i, slot] of slots.entries()) {
|
||||
const pred = prefixes[i]!.commit;
|
||||
const m = mergeStep(repoRoot, pred, slot.head, policy);
|
||||
const m = mergeCommit(repoRoot, pred, slot.head, policy, `train T${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)}`);
|
||||
if ('conflicts' in m) return { stoppedAt: slot.pr, conflicts: m.conflicts, merged: slots.slice(0, i).map(s => s.pr) };
|
||||
const c = git(repoRoot, ['commit-tree', m.tree, '-p', pred, '-p', slot.head, '-m', `train T${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)}`], { ...process.env, ...TRAIN_IDENTITY });
|
||||
if (!c.ok) return { stoppedAt: slot.pr, conflicts: [`(commit-tree failed: ${c.err.slice(0, 120)})`], merged: slots.slice(0, i).map(s => s.pr) };
|
||||
prefixes.push({ index: i + 1, commit: c.out, tree: m.tree, pr: slot.pr });
|
||||
prefixes.push({ index: i + 1, commit: m.commit, tree: m.tree, pr: slot.pr });
|
||||
}
|
||||
return { prefixes };
|
||||
}
|
||||
@@ -266,7 +279,11 @@ export function gateDispatch(env: TrainEnv, train: TrainRecord, nonce: string):
|
||||
return { workflow: env.train.gate_workflow, inputs: { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: train.head, [DISPATCH_INPUTS.nonce]: nonce } };
|
||||
}
|
||||
|
||||
export interface BuildOptions { pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
|
||||
export interface BuildOptions {
|
||||
pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number;
|
||||
/** `--dry-run --stack-tests`: run each PR's new test files on its integrated prefix (lib/ship-queue-stack-tests.ts); the command's words when `--stack-tests-command` names one. */
|
||||
stackTests?: string[] | true;
|
||||
}
|
||||
|
||||
/** `--order <pr>,<pr>,...`: every pinned PR exactly once (a leading # allowed). */
|
||||
export function parseOrder(spec: string | undefined, pins: Array<{ pr: string }>): string[] | undefined | { error: string } {
|
||||
@@ -279,7 +296,7 @@ export function parseOrder(spec: string | undefined, pins: Array<{ pr: string }>
|
||||
}
|
||||
|
||||
/** The slots a new train would carry: trusted, unmoved, READY PRs, in slot order. */
|
||||
function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { slots: TrainSlot[]; lines: string[] } | TrainOut {
|
||||
function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dryRun: boolean): { slots: TrainSlot[]; lines: string[] } | TrainOut {
|
||||
const { ctx } = env;
|
||||
const specs = pins.map(p => `+refs/pull/${p.pr}/head:refs/gstack/train/pr-${p.pr}`);
|
||||
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, ...specs]);
|
||||
@@ -302,9 +319,16 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
|
||||
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
|
||||
let screen = ready.screen;
|
||||
if (screen.choice === 'screen' && screen.run && slug === undefined) slug = repoSlug(ctx);
|
||||
const plan = screen.choice === 'screen' && screen.run ? (slug ? hollowScreen(ctx, slug, screen.run) : { kind: 'unknown' as const, reason: 'the repository slug is unreadable' }) : null;
|
||||
if (plan?.kind === 'hollow') { lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; }
|
||||
else if (plan?.kind === 'unknown') lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as screened, unverified`);
|
||||
const read = screen.choice === 'screen' && screen.run ? (slug ? screenVerdict(ctx, slug, screen.run) : { kind: 'unknown' as const, detail: 'the repository slug is unreadable' }) : null;
|
||||
if (read && screen.run && recordVerdict(ready.screen, read) && !dryRun) { ready.updated_at = nowIso(); persist(ctx.repoRoot, [ready], { remote: env.remote }); }
|
||||
if (read?.kind === 'red') {
|
||||
return outRefused({ code: 'QUEUE_SCREEN_RED', message: `#${p.pr}'s screen run ${screen.run} is RED on ${read.red.join(', ') || 'its gate binding'} (${read.detail}); a red head is not seated` },
|
||||
[...lines, ...verdictLines(p.pr, screen.run!, read)], without ? `${Q} train build --prs ${without}` : 'none', 'none');
|
||||
}
|
||||
if (read?.kind === 'not-run' || read?.kind === 'incomplete') { lines.push(`SCREEN: #${p.pr} ${read.detail}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; }
|
||||
else if (read?.kind === 'unknown') lines.push(`SCREEN: #${p.pr} ${read.detail}; counted as screened, unverified`);
|
||||
else if (read?.kind === 'green') lines.push(`SCREEN: #${p.pr} run ${screen.run} GREEN${read.flaky.length ? ` (flaky: ${read.flaky.join(', ')})` : ''}`);
|
||||
else if (read?.kind === 'pending') lines.push(`SCREEN: #${p.pr} run ${screen.run} still running; counted as screened`);
|
||||
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen });
|
||||
}
|
||||
return { slots, lines };
|
||||
@@ -321,7 +345,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, 'none');
|
||||
const explicit = parseOrder(o.order, pins);
|
||||
if (explicit && 'error' in explicit) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: explicit.error }, [], 'none', 'none');
|
||||
const admitted = admitSlots(env, pins);
|
||||
const admitted = admitSlots(env, pins, o.dryRun);
|
||||
if ('exit' in admitted) return admitted;
|
||||
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
|
||||
const lines = [
|
||||
@@ -460,8 +484,18 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<Tr
|
||||
}
|
||||
if (!('kind' in t)) return t;
|
||||
if (o.dryRun) {
|
||||
return outOk([...lines, ...describe(t), `ESTIMATE: one ${env.train.gate_workflow} full gate (evals_all) on T${t.prefixes.length - 1} plus ${env.train.free_workflow}; dollar figure pending the T0 cost replay (lib/gate-cost.ts)`,
|
||||
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, 'none');
|
||||
const dry = [...lines, ...describe(t), `ESTIMATE: one ${env.train.gate_workflow} full gate (evals_all) on T${t.prefixes.length - 1} plus ${env.train.free_workflow}; dollar figure pending the T0 cost replay (lib/gate-cost.ts)`];
|
||||
if (o.stackTests) {
|
||||
const results = stackTests(env.ctx.repoRoot, t.prefixes[0]!.commit, t.slots, t.prefixes, o.stackTests === true ? undefined : o.stackTests);
|
||||
dry.push(...results.map(stackTestLine));
|
||||
const red = results.filter(r => !r.ok);
|
||||
if (red.length) {
|
||||
const again = `${Q} train build ${prsArgs(t.slots, t.explicit_order)} --dry-run --stack-tests`;
|
||||
return outFailed({ code: 'QUEUE_TRAIN_STACK_RED', message: red.map(r => `#${r.pr}'s new tests fail on the integrated prefix T${r.prefix} (${r.files.join(', ')})`).join('; ') + '; they pass on the PR head alone only if nothing before it in the train changes what they read' },
|
||||
[...dry, 'DRY-RUN: nothing written, pushed or dispatched'], `fix ${red.map(r => `#${r.pr}'s tests against T${r.prefix}`).join(' and ')}, or reorder with --order, then ${again}`, 'none');
|
||||
}
|
||||
}
|
||||
return outOk([...dry, 'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, 'none');
|
||||
}
|
||||
if (t.state === 'built' && !t.attempts.length && !loadTrain(t.nonce)) {
|
||||
effect(t, 'train.json written');
|
||||
@@ -528,21 +562,64 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut
|
||||
return outOk(lines, next, single ? trainFile(single.nonce) : 'none');
|
||||
}
|
||||
|
||||
/** `train prune`: delete train/<nonce> on the remote for every finished (merged, superseded, dropped) train. */
|
||||
export function pruneTrains(ctx: QueueContext, o: { dryRun: boolean; train?: string }): TrainOut {
|
||||
/** A PR's state on GitHub for the stale check: MERGED, CLOSED, OPEN, or unreadable. */
|
||||
function prState(ctx: QueueContext, pr: string): string {
|
||||
const info = prInfo(ctx, pr);
|
||||
return 'error' in info ? 'unreadable' : info.state.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* `--stale`: a merging or green train whose every PR is MERGED or CLOSED on
|
||||
* GitHub (merged elsewhere, closed, or merged outside the train) never
|
||||
* finishes on its own; it becomes `stale` (terminal) and is pruned, with
|
||||
* each PR's state in the log. Any OPEN or unreadable PR keeps the train.
|
||||
*/
|
||||
function staleCheck(ctx: QueueContext, t: TrainRecord): { stale: true; detail: string } | { stale: false; detail: string } {
|
||||
const unmerged = t.slots.filter(s => !t.merged.some(m => m.pr === s.pr));
|
||||
const states = unmerged.map(s => `#${s.pr} ${prState(ctx, s.pr)}`);
|
||||
const open = states.filter(s => !/ (MERGED|CLOSED)$/.test(s));
|
||||
const detail = `${t.merged.map(m => `#${m.pr} merged by the train`).concat(states).join(', ')}`;
|
||||
return open.length ? { stale: false, detail: `${open.join(', ')} still ${open.length === 1 ? 'needs' : 'need'} the train` } : { stale: true, detail };
|
||||
}
|
||||
|
||||
/** `train prune`: delete train/<nonce> on the remote for every finished (merged, superseded, dropped, stale) train; `--stale` first settles merging and green trains whose PRs all merged or closed elsewhere, and the screen/<pr>-* branches of merged or closed PRs. */
|
||||
export function pruneTrains(ctx: QueueContext, o: { dryRun: boolean; train?: string; stale?: boolean }): TrainOut {
|
||||
const env = trainEnv(ctx);
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const lines: string[] = [];
|
||||
const rgit = (args: string[]) => git(ctx.repoRoot, args);
|
||||
const remove = (branch: string, label: string, after?: () => void) => {
|
||||
if (!remoteBranchSha(rgit, env.remote, branch)) { lines.push(`GONE: ${branch} (${label})`); return; }
|
||||
if (o.dryRun) { lines.push(`WOULD-DELETE: ${branch} (${label})`); return; }
|
||||
const d = git(ctx.repoRoot, ['push', '-q', env.remote, '--delete', `refs/heads/${branch}`]);
|
||||
lines.push(d.ok ? `DELETED: ${branch} (${label})` : `FAILED: ${branch}: ${d.err.slice(0, 120)}`);
|
||||
if (d.ok) after?.();
|
||||
};
|
||||
for (const t of listTrains(env.repo.keys).filter(x => !o.train || x.nonce === o.train)) {
|
||||
if (o.stale && (t.state === 'merging' || t.state === 'green')) {
|
||||
const s = staleCheck(ctx, t);
|
||||
if (s.stale) {
|
||||
lines.push(`STALE: ${t.nonce} (${t.state}): ${s.detail}${o.dryRun ? '; would mark stale' : ''}`);
|
||||
if (!o.dryRun) { t.state = 'stale'; effect(t, `marked stale by train prune --stale: ${s.detail}`); save(env, t); }
|
||||
remove(t.branch, o.dryRun ? 'stale' : t.state, () => { effect(t, `branch deleted: ${t.branch}`); save(env, t); });
|
||||
continue;
|
||||
}
|
||||
lines.push(`KEEP: ${t.branch} (${t.state}; ${s.detail})`);
|
||||
continue;
|
||||
}
|
||||
if (!TERMINAL_STATES.includes(t.state)) { lines.push(`KEEP: ${t.branch} (${t.state})`); continue; }
|
||||
if (!remoteBranchSha(rgit, env.remote, t.branch)) { lines.push(`GONE: ${t.branch} (${t.state})`); continue; }
|
||||
if (o.dryRun) { lines.push(`WOULD-DELETE: ${t.branch} (${t.state})`); continue; }
|
||||
const d = git(ctx.repoRoot, ['push', '-q', env.remote, '--delete', `refs/heads/${t.branch}`]);
|
||||
lines.push(d.ok ? `DELETED: ${t.branch} (${t.state})` : `FAILED: ${t.branch}: ${d.err.slice(0, 120)}`);
|
||||
if (d.ok) { effect(t, `branch deleted: ${t.branch}`); save(env, t); }
|
||||
remove(t.branch, t.state, () => { effect(t, `branch deleted: ${t.branch}`); save(env, t); });
|
||||
}
|
||||
if (o.stale && !o.train) {
|
||||
const refs = git(ctx.repoRoot, ['ls-remote', env.remote, 'refs/heads/screen/*']);
|
||||
for (const ref of refs.ok ? refs.out.split('\n') : []) {
|
||||
const m = /refs\/heads\/(screen\/(\d+)-[0-9a-f]{12})$/.exec(ref.split(/\s/)[1] ?? '');
|
||||
if (!m) continue;
|
||||
const state = prState(ctx, m[2]!);
|
||||
if (state === 'MERGED' || state === 'CLOSED') remove(m[1]!, `#${m[2]} ${state}`); else lines.push(`KEEP: ${m[1]} (#${m[2]} ${state})`);
|
||||
}
|
||||
}
|
||||
if (!lines.length) lines.push('PRUNE: no trains');
|
||||
return outOk(lines, o.dryRun ? `${Q} train prune` : 'none', 'none');
|
||||
return outOk(lines, o.dryRun ? `${Q} train prune${o.stale ? ' --stale' : ''}` : 'none', 'none');
|
||||
}
|
||||
+48
-7
@@ -8,6 +8,12 @@
|
||||
* CI at that exact head, the PREGATE line, the gate state and the
|
||||
* /ship receipt; refused when the tree is not the pushed PR head
|
||||
* or the PR body carries no receipt for it (QUEUE_RECEIPT_*).
|
||||
* A pregate record missing from this checkout, unreadable,
|
||||
* recorded for another tree, or current but still waiting on a
|
||||
* remote receipt, is looked up by the current tree under
|
||||
* <state root>/pregate/by-tree/ (gstack-pregate mirrors every
|
||||
* 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: <pr> <version> head= gated=
|
||||
@@ -21,7 +27,8 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
|
||||
import type { PregateFile } from './headless-artifacts';
|
||||
import { CHECKS, 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';
|
||||
@@ -30,6 +37,7 @@ import {
|
||||
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
|
||||
type AttemptRecord, type QueueFailure,
|
||||
} from './ship-queue-gate';
|
||||
import { wtreeFingerprint } from './script-invocation';
|
||||
import { buildReceipt, type TreeReceipt } from './tree-receipt';
|
||||
|
||||
export type Gh = (args: string[]) => { ok: boolean; out: string; err?: string };
|
||||
@@ -138,20 +146,21 @@ export function latestRunPerName<T extends { name?: unknown; id?: unknown; statu
|
||||
}
|
||||
|
||||
/** CI at one exact SHA: the latest check run per name (as GitHub's rollup counts) and every commit-status context; unreadable or incomplete is unknown, never green. */
|
||||
function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean } {
|
||||
export function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean; checks: Array<{ name: string; status: string; conclusion: string | null }> } {
|
||||
const at = sha.slice(0, 12);
|
||||
const runs = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs');
|
||||
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false };
|
||||
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false, checks: [] };
|
||||
const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses');
|
||||
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false };
|
||||
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false, checks: [] };
|
||||
const checks = latestRunPerName(runs.items).map(c => ({ name: String(c.name ?? ''), status: String(c.status ?? ''), conclusion: c.conclusion === undefined || c.conclusion === null ? null : String(c.conclusion) }));
|
||||
const verdicts = [
|
||||
...latestRunPerName(runs.items).map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
|
||||
...checks.map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
|
||||
...statuses.items.map(s => s.state === 'success' ? 'passed' : s.state === 'pending' ? 'pending' : 'failed'),
|
||||
];
|
||||
const count = (v: string) => verdicts.filter(x => x === v).length;
|
||||
const [passed, failed, pending, skipped] = [count('passed'), count('failed'), count('pending'), count('skipped')];
|
||||
const green = verdicts.length > 0 && pending === 0 && failed === 0;
|
||||
return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0 };
|
||||
return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0, checks };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +187,22 @@ 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 <file>)` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a by-tree pregate 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; what the lane case needs from it is the test tier that
|
||||
* `gstack-pregate --stage tests --remote` ran in the clone, so the mirror
|
||||
* must hold every tests-stage check (a `--only timing` or preflight-only run
|
||||
* is not evidence), be recorded against the same base, and not come from
|
||||
* `--policy-from head`.
|
||||
*/
|
||||
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 = CHECKS.filter(c => c.stage === 'tests' && !file.checks.some(k => k.id === c.id)).map(c => c.id);
|
||||
return missing.length ? `a run without the tests stage (${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';
|
||||
@@ -205,14 +230,30 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL);
|
||||
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 });
|
||||
// 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 (the diff's doing
|
||||
// or the environment's) is final.
|
||||
let useMirror = true;
|
||||
if (fs.existsSync(pregateFile)) {
|
||||
const read = readPregateFile(pregateFile);
|
||||
if (read.file) {
|
||||
const v = verifyPregate(ctx.repoRoot, 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' && v.code !== 'PREGATE_ENVIRONMENT';
|
||||
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 (useMirror && 'tree' in treeNow) {
|
||||
const mirror = pregateMirrorPath(treeNow.tree);
|
||||
const read = fs.existsSync(mirror) ? readPregateFile(mirror) : 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 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);
|
||||
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.260",
|
||||
"version": "1.91.264",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -295,6 +295,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-queue-train-cost.test.ts',
|
||||
reason: 'drives bin/gstack-ship-queue train verdict against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard 2 on #3478 at d5e6a7d)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-queue-train-ops.test.ts',
|
||||
reason: 'drives bin/gstack-ship-queue (screen verdicts, scratch-merge screens, prune --stale, watch, forget, verify --post-merge, ready --dispatch-ci, --stack-tests) against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-policy-compat.test.ts',
|
||||
reason: 'spawns bin/gstack-ship-policy, bin/gstack-restamp and bin/gstack-tree-receipt (shebangs) through test/helpers/restamp-fixture.ts',
|
||||
|
||||
+85
-35
@@ -7,17 +7,27 @@
|
||||
* hotfix (--ref <sha on main>). Dry runs print the plan and change nothing.
|
||||
*
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]
|
||||
* [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
|
||||
*
|
||||
* The checkout needs origin's full history (fetch-depth 0). Green, for a
|
||||
* commit on main's first-parent history, means: its free-tests check run
|
||||
* succeeded (the status main requires), its main-backstop check run succeeded
|
||||
* (evals-main-backstop.yml publishes it: success only when a green train
|
||||
* record covers the tree or the backstop gated it green; a push main moved
|
||||
* past before it was gated is neutral, never green), every check run on it
|
||||
* has completed and none failed, and refs/notes/gstack-trains holds no active
|
||||
* freeze. The newest such commit in the window (7 days, 200 first-parent
|
||||
* commits) is the candidate; a commit with a check run still going is
|
||||
* skipped, not waited for.
|
||||
* commit on main's first-parent history, means: its required check run
|
||||
* succeeded (--required-check, default free-tests: the status main requires),
|
||||
* it was gated (see below), every check run on it has completed and none
|
||||
* failed, and the trains notes ref (--notes-ref, default
|
||||
* refs/notes/gstack-trains) holds no active freeze. The newest such commit in
|
||||
* the window (7 days, 200 first-parent commits) is the candidate; a commit
|
||||
* with a check run still going is skipped, not waited for.
|
||||
*
|
||||
* Gated means one of two kinds of evidence. Either the backstop check run
|
||||
* (--backstop-check, default main-backstop) succeeded: evals-main-backstop.yml
|
||||
* publishes it, success only when a green train record covers the tree or the
|
||||
* backstop gated it green, neutral when main moved past the push before it was
|
||||
* gated. Or a train record in the notes ref (kind train, state green, merging
|
||||
* or merged) lists the commit's sha or its tree in merged[]: those entries are
|
||||
* the main commits `gstack-ship-queue verify --train` confirmed, so a neutral
|
||||
* or absent backstop run on such a commit is not held against it, and the
|
||||
* CANDIDATE line says "gated by train <nonce>". A red backstop run stays red
|
||||
* either way. A commit with neither is not green.
|
||||
*
|
||||
* stable moves only forward: a candidate behind the current stable is refused
|
||||
* (REFUSED non-fast-forward), as is a tag v<VERSION> that already names another
|
||||
@@ -66,43 +76,74 @@ export function compareVersions(a: string, b: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export type Verdict = { green: true } | { green: false; why: string; pending: boolean };
|
||||
/** Green names what gated the commit: the backstop check's name, or `train <nonce>`. Pending means a check run on it is still going. */
|
||||
export type Verdict = { green: true; via: string } | { green: false; why: string; pending: boolean };
|
||||
interface CheckRun { name: string; status: string; conclusion: string | null }
|
||||
|
||||
/** Why a commit is not releasable, or green. Pending means a check run on it is still going. */
|
||||
export function gateVerdict(gh: Gh, repo: string, sha: string): Verdict {
|
||||
/** Main commits a green, merging or merged train record lists in merged[], by sha and by tree, each naming the train. */
|
||||
export interface TrainEvidence { bySha: Map<string, string>; byTree: Map<string, string> }
|
||||
export interface GateOptions { requiredCheck?: string; backstopCheck?: string; evidence?: TrainEvidence; treeOf?: (sha: string) => string | null }
|
||||
|
||||
/** Why a commit is not releasable, or green and through which evidence. */
|
||||
export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions = {}): Verdict {
|
||||
const required = o.requiredCheck ?? REQUIRED_CHECK;
|
||||
const backstop = o.backstopCheck ?? BACKSTOP_CHECK;
|
||||
const paged = parseJson<unknown>(gh(['api', '--paginate', '--slurp', `repos/${repo}/commits/${sha}/check-runs?per_page=100`]));
|
||||
if (!paged) return { green: false, why: 'cannot read its check runs', pending: false };
|
||||
const pages = (Array.isArray(paged) ? paged : [paged]) as Array<{ check_runs?: CheckRun[] }>;
|
||||
const runs = pages.flatMap(p => p.check_runs ?? []);
|
||||
for (const name of [REQUIRED_CHECK, BACKSTOP_CHECK]) {
|
||||
let via = backstop;
|
||||
for (const name of [required, backstop]) {
|
||||
const named = runs.filter(c => c.name === name);
|
||||
if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
|
||||
if (named.some(c => c.status !== 'completed')) return { green: false, why: `${name} is still running`, pending: true };
|
||||
if (!named.some(c => c.conclusion === 'success')) return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false };
|
||||
if (named.some(c => c.conclusion === 'success')) continue;
|
||||
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 };
|
||||
}
|
||||
const red = runs.filter(c => c.status === 'completed' && RED.has(c.conclusion ?? ''));
|
||||
if (red.length > 0) return { green: false, why: `red check runs: ${[...new Set(red.map(c => c.name))].join(', ')}`, pending: false };
|
||||
const running = runs.filter(c => c.status !== 'completed');
|
||||
if (running.length > 0) return { green: false, why: `check runs still going: ${[...new Set(running.map(c => c.name))].join(', ')}`, pending: true };
|
||||
return { green: true };
|
||||
return { green: true, via };
|
||||
}
|
||||
|
||||
/** An active freeze record (kind freeze, cleared_at null) in the trains notes ref, after fetching it. */
|
||||
export function activeFreeze(git: Git): string | null {
|
||||
const f = git(['fetch', '--no-tags', '-q', 'origin', `+${NOTES_REF}:${NOTES_LOCAL}`]);
|
||||
if (!f.ok) return /couldn't find remote ref|no such ref/i.test(f.out) ? null : `cannot fetch ${NOTES_REF}: ${f.out.slice(0, 160)}`;
|
||||
export interface TrainNotes { error: string | null; freeze: string | null; evidence: TrainEvidence }
|
||||
interface NoteRecord { kind?: string; state?: string; nonce?: string; merged?: Array<{ sha?: string; tree?: string }>; cleared_at?: string | null; reason?: string; train?: string }
|
||||
|
||||
/**
|
||||
* One read of the trains notes ref (fetched from origin): the active freeze
|
||||
* record (kind freeze, cleared_at null) and the train evidence (lib/
|
||||
* ship-queue-train-record.ts TrainRecord: kind train, state green, merging or
|
||||
* merged, merged[].sha and .tree). A fetch failure other than a missing ref is
|
||||
* an error; a missing ref holds nothing.
|
||||
*/
|
||||
export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
|
||||
const notes: TrainNotes = { error: null, freeze: null, evidence: { bySha: new Map(), byTree: new Map() } };
|
||||
const f = git(['fetch', '--no-tags', '-q', 'origin', `+${notesRef}:${NOTES_LOCAL}`]);
|
||||
if (!f.ok) {
|
||||
if (!/couldn't find remote ref|no such ref/i.test(f.out)) notes.error = `cannot fetch ${notesRef}: ${f.out.slice(0, 160)}`;
|
||||
return notes;
|
||||
}
|
||||
const listed = git(['notes', `--ref=${NOTES_LOCAL}`, 'list']);
|
||||
if (!listed.ok || !listed.out.trim()) return null;
|
||||
if (!listed.ok || !listed.out.trim()) return notes;
|
||||
for (const line of listed.out.split('\n')) {
|
||||
const blob = line.split(' ')[0];
|
||||
if (!blob) continue;
|
||||
try {
|
||||
const r = JSON.parse(git(['cat-file', 'blob', blob]).out) as { kind?: string; cleared_at?: string | null; reason?: string; train?: string };
|
||||
if (r.kind === 'freeze' && !r.cleared_at) return `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`;
|
||||
} catch { /* not a record */ }
|
||||
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' || 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' && m.sha) notes.evidence.bySha.set(m.sha, r.nonce);
|
||||
if (typeof m.tree === 'string' && m.tree) notes.evidence.byTree.set(m.tree, r.nonce);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return notes;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry { version: string; date: string; headline: string }
|
||||
@@ -158,13 +199,16 @@ export function releaseNotes(n: NotesInput): string {
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
sha: string; version: string; tag: string; date: string; hotfix: boolean;
|
||||
sha: string; version: string; tag: string; date: string; hotfix: boolean; via: string;
|
||||
prevStable: string | null; prevVersion: string | null; prevTag: string | null;
|
||||
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
|
||||
}
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED'; message: string; skipped?: string[] };
|
||||
|
||||
export interface PlanOptions { repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number }
|
||||
export interface PlanOptions {
|
||||
repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number;
|
||||
requiredCheck?: string; backstopCheck?: string; notesRef?: string;
|
||||
}
|
||||
|
||||
export function planRelease(o: PlanOptions): PlanResult {
|
||||
const { git, gh, repo } = o;
|
||||
@@ -174,8 +218,13 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out };
|
||||
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
|
||||
const prevStable = stable.ok ? stable.out : null;
|
||||
const frozen = activeFreeze(git);
|
||||
const trains = readTrainNotes(git, o.notesRef);
|
||||
const frozen = trains.error ?? trains.freeze;
|
||||
if (frozen) return { ok: false, code: 'REFUSED', message: frozen };
|
||||
const gate: GateOptions = {
|
||||
requiredCheck: o.requiredCheck, backstopCheck: o.backstopCheck, evidence: trains.evidence,
|
||||
treeOf: (sha) => { const t = git(['rev-parse', sha + '^{tree}']); return t.ok ? t.out : null; },
|
||||
};
|
||||
|
||||
let candidates: string[];
|
||||
if (o.ref) {
|
||||
@@ -194,9 +243,10 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
// stable itself stays a candidate so a cut that stopped half-way (tag and
|
||||
// stable pushed, release missing) is finished; nothing older is looked at.
|
||||
let sha: string | null = null;
|
||||
let via = '';
|
||||
for (const c of candidates) {
|
||||
const v = gateVerdict(gh, repo, c);
|
||||
if (v.green) { sha = c; break; }
|
||||
const v = gateVerdict(gh, repo, c, gate);
|
||||
if (v.green) { sha = c; via = v.via; break; }
|
||||
skipped.push(c.slice(0, 12) + ': ' + v.why);
|
||||
if (prevStable && c === prevStable) { skipped.push(c.slice(0, 12) + ': already stable; nothing newer is green'); break; }
|
||||
}
|
||||
@@ -240,7 +290,7 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const changelog = git(['show', sha + ':CHANGELOG.md']);
|
||||
const date = git(['show', '-s', '--format=%cs', sha]).out;
|
||||
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before });
|
||||
return { ok: true, plan: { sha, version, tag, date, hotfix: Boolean(o.ref), prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } };
|
||||
return { ok: true, plan: { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } };
|
||||
}
|
||||
|
||||
export interface CutResult { ok: boolean; lines: string[] }
|
||||
@@ -283,7 +333,7 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
|
||||
|
||||
export function describePlan(p: Plan, dryRun: boolean): string[] {
|
||||
const lines = [
|
||||
'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : ''),
|
||||
'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : '') + ' gated by ' + p.via,
|
||||
'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
|
||||
'STABLE: ' + (p.stableAt ? 'already at ' + p.sha.slice(0, 12) + ' (finishing an earlier cut)' : (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12)),
|
||||
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
|
||||
@@ -307,13 +357,13 @@ if (import.meta.main) {
|
||||
const repo = arg('--repo');
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const summary = arg('--summary');
|
||||
if (!repo) { console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]'); process.exit(2); }
|
||||
if (!repo) { console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]'); process.exit(2); }
|
||||
const git = gitIn(process.cwd());
|
||||
const emit = (lines: string[]) => {
|
||||
for (const l of lines) console.log(l);
|
||||
if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
|
||||
};
|
||||
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh });
|
||||
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref') });
|
||||
if (!planned.ok) {
|
||||
emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]);
|
||||
// A dry run that finds nothing green has done its job (the skips say why);
|
||||
|
||||
+241
-1
@@ -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 }
|
||||
|
||||
@@ -226,6 +226,47 @@ 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,
|
||||
// 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 <id>" 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 = [
|
||||
"failed 40269992077 | rerun passed 40287239206",
|
||||
"screen 40269992077 (#3434) and 40275443393 (#3453)",
|
||||
"gate 40323989742",
|
||||
"gates: 40323989742, 40323989743 or 40323989744",
|
||||
"backstop=40323989742 bisect #40323989745",
|
||||
"dispatched 40323989742 / 40323989746 & 40323989747",
|
||||
"runs 40323989742 and 40323989753 (#3454), 40323989754",
|
||||
];
|
||||
for (const body of clean) {
|
||||
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
|
||||
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.posted]);
|
||||
expect(f.posts()).toHaveLength(1);
|
||||
}
|
||||
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",
|
||||
"call +14155551234 for the on-call",
|
||||
"run +14155551234",
|
||||
"see 40323989742, 40323989743",
|
||||
];
|
||||
for (const body of controls) {
|
||||
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
|
||||
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.confirm]);
|
||||
expect(f.stdout()).toContain("RULE: pii.phone.e164 LINE: 1 PART: body");
|
||||
expect(f.posts()).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("the title is scanned too, and the pusher's own email is not a finding", () => {
|
||||
const f = fakeEnv({ files: { "/t": "v1 fix for jane.roe@acme-corp.io", "/b": "body" } });
|
||||
expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], f)).toBe(EXIT.confirm);
|
||||
@@ -263,3 +304,202 @@ describe("gstack-post reports CLI failures and repairs known edit failures", ()
|
||||
expect(f.posts()[0]!.args.slice(0, 3)).toEqual(["pr", "comment", "3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
const RECEIPT = (head: string) => "```gstack-ship-receipt\n{\n \"schema_version\": 1,\n \"pr\": \"5\",\n \"head\": \"" + head + "\"\n}\n```\n";
|
||||
const CURRENT = RECEIPT("aaaa") + "\n## What changed\n\n- the old summary\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n";
|
||||
const withBody = (current: string, files: Record<string, string>) => fakeEnv({
|
||||
files,
|
||||
respond: c => (c.cmd === "gh" && c.args[1] === "view" && c.args.includes("body") ? { status: 0, stdout: current + "\n" } : undefined),
|
||||
});
|
||||
const sent = (f: ReturnType<typeof fakeEnv>) => f.posts().find(c => c.args[1] === "edit")!;
|
||||
|
||||
// Value: protects=a pr-body post holding one section replaces only that section: same-titled sections (case-insensitively) are replaced in place, the
|
||||
// receipt block is its own section, sections the new body lacks are kept, new sections are appended, and one stderr line names each group;
|
||||
// fails_when=pr-body overwrites the whole body (a "## What changed"-only post wipes Why, Approvals and the receipt), the receipt is folded into the
|
||||
// section before it, or the merge changes the kept sections' bytes; why_new=pr-body was a plain overwrite; seam=the fake run answers gh pr view --json body;
|
||||
// no_claim=real gh (unit through runPost)
|
||||
test("a first-section-only body keeps the other sections and the receipt; a same-titled section is replaced in place; new sections are appended", () => {
|
||||
const f = withBody(CURRENT, { "/b": "## What Changed\n\n- the new summary\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], f)).toBe(EXIT.posted);
|
||||
expect(sent(f).input).toBe(RECEIPT("aaaa") + "\n## What Changed\n\n- the new summary\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n");
|
||||
expect(f.stderr()).toBe("gstack-post: pr-body merged by section: replaced What changed; kept ship receipt, Why, Approvals; added none\n");
|
||||
expect(f.calls.find(c => c.cmd === "gh" && c.args[1] === "view")!.args).toEqual(["pr", "view", "5", "--json", "body", "--jq", ".body"]);
|
||||
|
||||
const receipt = withBody(CURRENT, { "/b": RECEIPT("bbbb") });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], receipt)).toBe(EXIT.posted);
|
||||
expect(sent(receipt).input).toBe(CURRENT.replace(RECEIPT("aaaa"), RECEIPT("bbbb")));
|
||||
expect(receipt.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt; kept What changed, Why, Approvals; added none\n");
|
||||
|
||||
const added = withBody(CURRENT, { "/b": "## Why\n\nStill because.\n\n## Tests\n\n- one\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], added)).toBe(EXIT.posted);
|
||||
expect(sent(added).input).toBe(RECEIPT("aaaa") + "\n## What changed\n\n- the old summary\n\n## Why\n\nStill because.\n\n## Approvals\n\n- owner: do it\n\n## Tests\n\n- one\n");
|
||||
expect(added.stderr()).toBe("gstack-post: pr-body merged by section: replaced Why; kept ship receipt, What changed, Approvals; added Tests\n");
|
||||
|
||||
const fenced = withBody("## Notes\n\n```md\n## not a heading\n```\n\n## Why\n\nold\n", { "/b": "## Why\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], fenced)).toBe(EXIT.posted);
|
||||
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 unclosed receipt fence; added ship receipt, Summary\n");
|
||||
|
||||
const prose = withBody(unclosed, { "/b": "Updated introduction\n\n## Summary\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], prose)).toBe(EXIT.posted);
|
||||
expect(sent(prose).input).toBe("Updated introduction\n\n" + unclosed + "\n## Summary\n\nnew\n");
|
||||
|
||||
const inside = withBody("Intro\n\n## Notes\n\n" + unclosed, { "/b": "New intro\n\n## Notes\n\nnew notes\n\n## Approvals\n\n- owner: still\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], inside)).toBe(EXIT.posted);
|
||||
expect(sent(inside).input).toBe("New intro\n\n## Notes\n\nnew notes\n\n" + unclosed + "\n## Approvals\n\n- owner: still\n");
|
||||
expect(inside.stderr()).toBe("gstack-post: pr-body merged by section: replaced leading text, Notes; kept unclosed receipt fence; added Approvals\n");
|
||||
|
||||
const after = withBody(RECEIPT("aaaa") + "\n## Why\n\nold\n\n" + unclosed, { "/b": RECEIPT("bbbb") + "\n## Why\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], after)).toBe(EXIT.posted);
|
||||
expect(sent(after).input).toBe(RECEIPT("bbbb") + "\n## Why\n\nnew\n\n" + unclosed);
|
||||
expect(after.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, Why; kept unclosed receipt fence; added none\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
|
||||
// kept section passes the scan, or a failed read overwrites; why_new=first pr-body merge tests; seam=fake run; no_claim=real gh
|
||||
test("--replace overwrites; a body without H2 headings is kept as a leading section; the merged bytes are what is scanned; a failed read refuses", () => {
|
||||
const replaced = withBody(CURRENT, { "/b": "## What changed\n\nonly this\n" });
|
||||
expect(run(["pr-body", "5", "--replace", "--body-file", "/b"], replaced)).toBe(EXIT.posted);
|
||||
expect(sent(replaced).input).toBe("## What changed\n\nonly this\n");
|
||||
expect(replaced.stderr()).toBe("");
|
||||
expect(replaced.calls.some(c => c.cmd === "gh" && c.args[1] === "view")).toBe(false);
|
||||
|
||||
const plain = withBody("Just a paragraph.\nAnother line.", { "/b": "## Tests\n\n- one\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], plain)).toBe(EXIT.posted);
|
||||
expect(sent(plain).input).toBe("Just a paragraph.\nAnother line.\n\n## Tests\n\n- one\n");
|
||||
expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text; added Tests\n");
|
||||
|
||||
const empty = withBody("", { "/b": "new body" });
|
||||
expect(run(["pr-body", "6", "--body-file", "/b"], empty)).toBe(EXIT.posted);
|
||||
expect(sent(empty).input).toBe("new body");
|
||||
|
||||
const secret = fakeEnv({ files: { "/b": "## What changed\n\nclean\n" }, respond: c => (c.cmd === "gh" && c.args[1] === "view" ? { status: 0, stdout: "## Keys\n\n" + AWS + "\n" } : undefined) });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], secret)).toBe(EXIT.refused);
|
||||
expect(secret.stdout()).toContain("HIGH RULE: ");
|
||||
expect(secret.posts().filter(c => c.args[1] === "edit")).toEqual([]);
|
||||
|
||||
const unreadable = fakeEnv({ files: { "/b": "## What changed\n\nx\n" }, respond: c => (c.cmd === "gh" && c.args[1] === "view" ? { status: 1, stderr: "GraphQL: Could not resolve to a PullRequest" } : undefined) });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], unreadable)).toBe(EXIT.cliFailed);
|
||||
expect(unreadable.stderr()).toContain("cannot read PR 5's current body");
|
||||
expect(unreadable.stderr()).toContain("--replace");
|
||||
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 <n> --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("<html>502</html>\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"]);
|
||||
});
|
||||
});
|
||||
@@ -292,6 +292,70 @@ describe('ready', () => {
|
||||
const stalePregate = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } }));
|
||||
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
|
||||
// <state root>/pregate/by-tree/<tree>.json; ready reads that mirror for the current tree when its own record is stale or missing, and only a run
|
||||
// that holds the tests stage (what `--stage tests --remote` writes: checks=[lanes]) against origin/main under a reviewed policy stands in: a
|
||||
// --only timing run, another base or a head policy is named on the PREGATE line instead.
|
||||
const stateRoot = path.join(repo.root, 'state-root');
|
||||
const complete = { base: `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}`, policy: 'origin/main@abc (no .gstack/pregate.json)', checks: [{ id: 'lanes', 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', ...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);
|
||||
const suffix = fromSuffix.replace('TREE', movedTree.slice(0, 12));
|
||||
const partial = { ...complete, checks: [{ id: 'timing', status: 'pass' }] };
|
||||
for (const [bad, why] of [[partial, 'a run without the tests stage (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=1 ${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(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,
|
||||
// including an incomplete check the environment caused (PREGATE_ENVIRONMENT, #3480: a secrets scanner that never finished is not a scanned diff);
|
||||
// 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=1 ${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.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'incomplete', checks: [{ id: 'secrets', status: 'incomplete', cause: 'environment', environment: { kind: 'missing-binary', evidence: 'gstack-redact: not on PATH', proof: 'launch' } }] }));
|
||||
const environmentLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(environmentLocal.status).toBe(EXIT.refused);
|
||||
expect(line(environmentLocal.out, 'PREGATE:')).toMatch(/\(PREGATE_ENVIRONMENT\)$/);
|
||||
expect(environmentLocal.out).not.toContain(suffix);
|
||||
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);
|
||||
|
||||
@@ -31,13 +31,22 @@ export interface Rule {
|
||||
plan?: { planned: number } | { manifest: unknown } | null;
|
||||
/** Marks every artifact of runs dispatched under this rule expired in the artifacts listing. */
|
||||
expired?: boolean;
|
||||
/** A screen run's gate binding (gate-binding-a1/gate-binding.json, scripts/gate-reuse.ts) and its report's collector-outcomes.json (report-verdict-a1). */
|
||||
binding?: { verdict: 'GREEN' | 'RED'; reconcile_exit?: number; flaky?: string[] };
|
||||
outcomes?: { red?: string[]; flakyLines?: string[] };
|
||||
/** The run confirmed a verdict reuse (R2a/R2): paid-plan carries a valid gate-reuse.json naming this run and no binding is uploaded. */
|
||||
reuse?: { run: number; flaky?: string[] };
|
||||
check?: string | null;
|
||||
/** The check-run name a run dispatched under this rule attaches to its head (a push or workflow_dispatch run's job), with the run's status and conclusion. */
|
||||
ci?: string;
|
||||
bisect?: { prefixes: string[]; patterns: Record<string, string>; costUsd?: number };
|
||||
}
|
||||
export interface ShimData {
|
||||
repo: string; origin: string; nextId: number; prs: Record<string, unknown>; runs: Array<Record<string, unknown>>;
|
||||
artifacts: Record<string, Record<string, unknown>>; checks: Record<string, Array<{ name: string; status: string; conclusion: string | null }>>;
|
||||
ci: Record<string, unknown[]>; rules: Record<string, Rule>; artifact: string; log: Array<Record<string, unknown>>;
|
||||
/** Commit statuses (the legacy status API) at a sha, beside the check runs in `ci`. */
|
||||
statuses?: Record<string, Array<{ state: string; context: string }>>;
|
||||
}
|
||||
|
||||
export interface TrainFixture { repo: FixtureRepo; state: string; dataFile: string; env: Record<string, string>; read: () => ShimData; write: (fn: (d: ShimData) => void) => void }
|
||||
@@ -163,6 +172,13 @@ function shimSource(): string {
|
||||
" if (rule.record && !inputs.prefix_shas) d.artifacts[id][d.artifact] = { schema: 1, kind: 'train-gate', nonce: rule.record.nonce || inputs.nonce, head_sha: head, tree: rev(head + '^{tree}'),",
|
||||
" verdict: rule.record.verdict || 'GREEN', census: rule.record.census, panels: rule.record.panels };",
|
||||
" if (rule.check && !inputs.prefix_shas) (d.checks[head] = d.checks[head] || []).push({ name: 'gstack/full-gate', status: 'completed', conclusion: rule.check });",
|
||||
" if (rule.ci) (d.ci[head] = d.ci[head] || []).push({ name: rule.ci, status: run.status, conclusion: run.conclusion, id: id, completed_at: '2026-10-26T00:00:00Z' });",
|
||||
" if (rule.binding) d.artifacts[id]['gate-binding-a1'] = { schema: 1, repository: d.repo, pr: Number(inputs.screen_pr) || 0, run_id: id, run_attempt: 1, head_sha: rev(head + '^2') || head, base_sha: rev(head + '^1') || head,",
|
||||
" profile: 'pr', reconcile_exit: rule.binding.reconcile_exit === undefined ? (rule.binding.verdict === 'GREEN' ? 0 : 1) : rule.binding.reconcile_exit, verdict: rule.binding.verdict, manual_accepted: 0, selection_sha256: 'a'.repeat(64),",
|
||||
" flaky_cases: rule.binding.flaky || [], purpose: 'screen', tested_sha: head, screen_policy: inputs.screen_policy || 'narrow' };",
|
||||
" if (rule.reuse && d.artifacts[id]['paid-plan']) d.artifacts[id]['paid-plan'].__reuse = { schema: 1, kind: 'main-moved', pr: Number(inputs.screen_pr) || 0, reused_run_id: rule.reuse.run, reused_run_attempt: 1, gated_head: 'b'.repeat(40), current_head: 'c'.repeat(40),",
|
||||
" gated_base: 'd'.repeat(40), base_sha: 'e'.repeat(40), selection_sha256: 'a'.repeat(64), why: 'same own diff on a moved main', gated_merge_base: 'f'.repeat(40), merge_base: '1'.repeat(40), own_diff_sha256: 'b'.repeat(64), reused_screen_policy: 'narrow', reused_flaky: rule.reuse.flaky || [] };",
|
||||
" if (rule.outcomes) d.artifacts[id]['report-verdict-a1'] = { version: 2, panels: (rule.outcomes.red || []).map(c => ({ case: c, kind: 'judge', status: 'FAIL', failsLane: true, redClass: 'VERDICT' })), flakyLines: rule.outcomes.flakyLines || [] };",
|
||||
" if (rule.bisect && inputs.prefix_shas) for (const sha of inputs.prefix_shas.split(',')) {",
|
||||
" const at = rule.bisect.prefixes.indexOf(sha);",
|
||||
" d.artifacts[id][d.artifact + '-bisect-' + sha.slice(0, 12)] = { schema: 1, kind: 'train-bisect-prefix', nonce: inputs.nonce, prefix_sha: sha, checked_out_sha: sha,",
|
||||
@@ -178,8 +194,9 @@ function shimSource(): string {
|
||||
"if (a[0] === 'run' && a[1] === 'view') { const r = d.runs.find(x => String(x.databaseId) === a[2]); if (!r) fail('run not found'); out(r); }",
|
||||
"if (a[0] === 'run' && a[1] === 'download') {",
|
||||
" const art = (d.artifacts[a[2]] || {})[opt('-n')]; if (!art) fail('no artifact matches');",
|
||||
" fs.mkdirSync(opt('-D'), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), opt('-n') === 'paid-plan' ? 'manifest.json' : 'record.json'), JSON.stringify(art));",
|
||||
" if (opt('-n') === 'paid-plan') fs.writeFileSync(path.join(opt('-D'), 'gate-reuse.json'), '{\"reuse\":\"confirmed\"}'); save(); process.exit(0);",
|
||||
" const fileFor = n => n === 'paid-plan' ? 'manifest.json' : n.startsWith('gate-binding-') ? 'gate-binding.json' : n.startsWith('report-verdict-') ? 'paid-report/collector-outcomes.json' : 'record.json';",
|
||||
" fs.mkdirSync(path.dirname(path.join(opt('-D'), fileFor(opt('-n')))), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), fileFor(opt('-n'))), JSON.stringify(art));",
|
||||
" if (opt('-n') === 'paid-plan') fs.writeFileSync(path.join(opt('-D'), 'gate-reuse.json'), art.__reuse ? JSON.stringify(art.__reuse) : '{\"reuse\":\"confirmed\"}'); save(); process.exit(0);",
|
||||
"}",
|
||||
"if (a[0] === 'api') {",
|
||||
" const arts = /actions\\/runs\\/(\\d+)\\/artifacts(?:\\?name=([^&]+))?/.exec(a[1] || '');",
|
||||
@@ -187,7 +204,7 @@ function shimSource(): string {
|
||||
" const named = /commits\\/([0-9a-f]+)\\/check-runs\\?check_name=([^&]+)/.exec(a[1] || '');",
|
||||
" if (named) { const list = (d.checks[named[1]] || []).filter(c => c.name === decodeURIComponent(named[2])); out({ total_count: list.length, check_runs: list }); }",
|
||||
" const paged = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || '');",
|
||||
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : []; const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
|
||||
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : ((d.statuses || {})[paged[1]] || []); const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
|
||||
" out(paged[3] === '1' ? { total_count: list.length, state: 'success', [key]: list } : { total_count: list.length, [key]: [] }); }",
|
||||
"}",
|
||||
"fail('gh shim: unsupported ' + a.join(' '));",
|
||||
|
||||
@@ -90,4 +90,35 @@ describe('pregate invalidation', () => {
|
||||
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);
|
||||
});
|
||||
+171
-3
@@ -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[] = [];
|
||||
@@ -154,13 +154,13 @@ describe('release-cut: planning', () => {
|
||||
const queued = checks([{ check_runs: [...GREEN, { name: 'slices-report', status: 'queued', conclusion: null }] }]);
|
||||
expect(gateVerdict(queued, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'check runs still going: slices-report', pending: true });
|
||||
const paged = checks([{ check_runs: [{ name: 'typecheck', status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }] }, { check_runs: [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }] }]);
|
||||
expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
|
||||
expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true, via: BACKSTOP_CHECK });
|
||||
const pagedRed = checks([{ check_runs: GREEN }, { check_runs: [{ name: 'windows-free-tests', status: 'completed', conclusion: 'timed_out' }] }]);
|
||||
expect(gateVerdict(pagedRed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: windows-free-tests', pending: false });
|
||||
const cancelled = checks([{ check_runs: [...GREEN, { name: 'quality', status: 'completed', conclusion: 'cancelled' }, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
|
||||
expect(gateVerdict(cancelled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: quality', pending: false });
|
||||
const skipped = checks([{ check_runs: [...GREEN, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
|
||||
expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
|
||||
expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true, via: BACKSTOP_CHECK });
|
||||
});
|
||||
|
||||
test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => {
|
||||
@@ -175,6 +175,174 @@ 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'] }, { sha: '', tree: '' }] }), 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', () => {
|
||||
const train = (nonce: string, state: string, merged: Array<{ sha: string; tree: string }>) =>
|
||||
({ schema: 1, kind: 'train', nonce, state, merged: merged.map(m => ({ pr: '1', at: 'x', ...m })), updated_at: 'x' });
|
||||
const note = (f: ReturnType<typeof fixture>, record: unknown, on: string, ref = NOTES_REF) => {
|
||||
git(f.seed, 'notes', '--ref=' + ref, 'add', '-f', '-m', JSON.stringify(record), on);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', ref);
|
||||
};
|
||||
const treeOf = (f: ReturnType<typeof fixture>, sha: string) => git(f.seed, 'rev-parse', sha + '^{tree}');
|
||||
|
||||
// Value: protects=a main commit whose main-backstop check run is neutral (superseded) or absent is released when a green, merging or merged train record's
|
||||
// merged[] names its sha or its tree, and the CANDIDATE line says which train gated it; fails_when=the gate still needs a successful backstop run on
|
||||
// every commit (the weekly cut returns NO_GREEN_COMMIT for a week of train-merged commits), evidence is matched on sha only, or the plan hides what gated
|
||||
// the commit; why_new=the planning tests know only the backstop check run as evidence; seam=none; no_claim=real refs/notes/gstack-trains content (a
|
||||
// fixture record shaped like lib/ship-queue-train-record.ts TrainRecord)
|
||||
test('a neutral backstop plus a train record naming the sha, or only the tree, is a candidate gated by that train', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
f.state.checks[head] = backstop('completed', 'neutral');
|
||||
note(f, train('t1', 'merged', [{ sha: head, tree: treeOf(f, head) }]), head);
|
||||
const bySha = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(bySha.ok, JSON.stringify(bySha)).toBe(true);
|
||||
if (!bySha.ok) return;
|
||||
expect(bySha.plan.sha).toBe(head);
|
||||
expect(bySha.plan.via).toBe('train t1');
|
||||
expect(describePlan(bySha.plan, true)[0]).toBe('CANDIDATE: ' + head + ' (' + bySha.plan.date + ') gated by train t1');
|
||||
|
||||
const byTree = fixture();
|
||||
const head2 = byTree.shas['1.0.3']!;
|
||||
delete byTree.state.checks[head2];
|
||||
byTree.state.checks[head2] = GREEN.filter(c => c.name !== BACKSTOP_CHECK);
|
||||
note(byTree, train('t2', 'green', [{ sha: 'f'.repeat(40), tree: treeOf(byTree, head2) }]), head2);
|
||||
const r = planRelease({ repo: byTree.repo, git: byTree.git, gh: byTree.gh });
|
||||
expect(r.ok, JSON.stringify(r)).toBe(true);
|
||||
if (r.ok) { expect(r.plan.sha).toBe(head2); expect(r.plan.via).toBe('train t2'); }
|
||||
|
||||
const plain = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
|
||||
expect(plain.ok).toBe(true);
|
||||
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 (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']!;
|
||||
const tree = treeOf(f, head);
|
||||
note(f, train('t1', 'merging', [{ sha: head, tree }]), head);
|
||||
const gate = (checks: CheckRun[]) => { f.state.checks[head] = checks; const v = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh }); return v.ok ? 'green' : v.message; };
|
||||
expect(gate([...backstop('completed', 'neutral'), { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: red check runs: windows-free-tests');
|
||||
expect(gate(backstop('completed', 'failure'))).toBe('--ref ' + head.slice(0, 12) + ' is not green: red check runs: ' + BACKSTOP_CHECK);
|
||||
expect(gate([{ name: BACKSTOP_CHECK, status: 'completed', conclusion: 'neutral' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: no ' + REQUIRED_CHECK + ' check run');
|
||||
expect(gate([{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'failure' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'neutral' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: ' + REQUIRED_CHECK + ' concluded failure');
|
||||
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);
|
||||
if (none.ok) return;
|
||||
expect(none.code).toBe('NO_GREEN_COMMIT');
|
||||
expect(none.message).toBe('no green commit among 4 on origin/main; see the skipped list');
|
||||
expect(none.skipped).toEqual(Object.values(f.shas).reverse().map(s => s.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral'));
|
||||
note(f, train('other', 'merged', [{ sha: f.shas['1.0.1']!, tree: treeOf(f, f.shas['1.0.1']!) }]), head);
|
||||
const older = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(older.ok, JSON.stringify(older)).toBe(true);
|
||||
if (!older.ok) return;
|
||||
expect(older.plan.sha).toBe(f.shas['1.0.1']);
|
||||
expect(older.plan.via).toBe('train other');
|
||||
expect(older.plan.skipped).toEqual([head.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral', f.shas['1.0.2']!.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral']);
|
||||
});
|
||||
|
||||
// Value: protects=--required-check, --backstop-check and --notes-ref rename what the gate reads so another repository can run the script, and the
|
||||
// defaults stay free-tests, main-backstop and refs/notes/gstack-trains for weekly-release.yml; fails_when=a renamed check is still looked up under the
|
||||
// default name, the train notes are read from the default ref only, or a flag is misread by the CLI; why_new=every other test runs under the defaults;
|
||||
// seam=none; no_claim=none beyond protects
|
||||
test('--required-check, --backstop-check and --notes-ref rename what the gate reads, through planRelease and the CLI', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
const renamed: CheckRun[] = [{ name: 'ci', status: 'completed', conclusion: 'success' }, { name: 'nightly-backstop', status: 'completed', conclusion: 'success' }];
|
||||
for (const sha of Object.values(f.shas)) f.state.checks[sha] = renamed;
|
||||
const defaults = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(defaults.ok).toBe(false);
|
||||
if (!defaults.ok) expect(defaults.skipped![0]).toBe(head.slice(0, 12) + ': no ' + REQUIRED_CHECK + ' check run');
|
||||
const flagged = planRelease({ repo: f.repo, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop' });
|
||||
expect(flagged.ok, JSON.stringify(flagged)).toBe(true);
|
||||
if (flagged.ok) { expect(flagged.plan.sha).toBe(head); expect(flagged.plan.via).toBe('nightly-backstop'); }
|
||||
|
||||
f.state.checks[head] = [{ name: 'ci', status: 'completed', conclusion: 'success' }, { name: 'nightly-backstop', status: 'completed', conclusion: 'neutral' }];
|
||||
note(f, train('t9', 'merged', [{ sha: head, tree: treeOf(f, head) }]), head, 'refs/notes/other-trains');
|
||||
const defaultRef = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop' });
|
||||
expect(defaultRef.ok).toBe(false);
|
||||
if (!defaultRef.ok) expect(defaultRef.message).toBe('--ref ' + head.slice(0, 12) + ' is not green: nightly-backstop concluded neutral');
|
||||
const otherRef = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop', notesRef: 'refs/notes/other-trains' });
|
||||
expect(otherRef.ok, JSON.stringify(otherRef)).toBe(true);
|
||||
if (otherRef.ok) expect(otherRef.plan.via).toBe('train t9');
|
||||
|
||||
if (process.platform === 'win32') return;
|
||||
const shim = path.join(f.base, 'shim');
|
||||
fs.mkdirSync(shim);
|
||||
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: f.state.checks[head] }]) + '\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 });
|
||||
const run = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), ...args],
|
||||
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, PATH: shim + path.delimiter + process.env.PATH } });
|
||||
const cli = run('--repo', f.repo, '--dry-run', '--ref', head, '--required-check', 'ci', '--backstop-check', 'nightly-backstop', '--notes-ref', 'refs/notes/other-trains');
|
||||
expect(cli.status, cli.stdout + cli.stderr).toBe(0);
|
||||
expect(cli.stdout.split('\n')[0]).toBe('CANDIDATE: ' + head + ' (' + git(f.seed, 'show', '-s', '--format=%cs', head) + ') [hotfix: --ref] gated by train t9');
|
||||
const unnamed = run('--repo', f.repo, '--dry-run', '--ref', head, '--required-check', 'ci', '--backstop-check', 'nightly-backstop');
|
||||
expect(unnamed.status).toBe(1);
|
||||
expect(unnamed.stdout.split('\n')[0]).toBe('REFUSED: --ref ' + head.slice(0, 12) + ' is not green: nightly-backstop concluded neutral');
|
||||
});
|
||||
});
|
||||
|
||||
describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Restamp mention scanning and rewriting (gbrain #7640, gstack 1.91.246.0): a
|
||||
* PR that stamped X lost its slot, merged the base, and the base was X by
|
||||
* then, so the branch's VERSION equalled the base's (no branch stamp) and its
|
||||
* quoted `since: 'X'` rows never moved: the mention scanner could not pass
|
||||
* the opening quote and, with no branch stamp, nothing else named X. Pins
|
||||
* the quoted forms (single, double, unquoted unchanged, quote style kept) in
|
||||
* both the scanner and the rewrite table, the equal-version rule (the base
|
||||
* version moves only on lines the branch added), and the negative control
|
||||
* (a base-side quoted row carrying that version stays).
|
||||
*/
|
||||
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 { rewriteStamps, tentativeMentions } from '../lib/restamp';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out);
|
||||
|
||||
describe('quoted stamp forms (pure)', () => {
|
||||
test('rewriteStamps: since: with a single or double quote moves and keeps its quote; the unquoted form is unchanged', () => {
|
||||
expect(rewriteStamps("{ since: '1.2.3.0', id: 'a' }", '1.2.3.0', '1.2.4.0')).toEqual({ text: "{ since: '1.2.4.0', id: 'a' }", count: 1 });
|
||||
expect(rewriteStamps('{ since: "1.2.3.0", id: "a" }', '1.2.3.0', '1.2.4.0')).toEqual({ text: '{ since: "1.2.4.0", id: "a" }', count: 1 });
|
||||
expect(rewriteStamps("since:'1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "since:'1.2.4.0'", count: 1 });
|
||||
expect(rewriteStamps('since: 1.2.3.0', '1.2.3.0', '1.2.4.0')).toEqual({ text: 'since: 1.2.4.0', count: 1 });
|
||||
// A quote is an exact boundary too: a longer version behind it is not X.
|
||||
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0.1'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
// An opened quote must close right after X: a quoted prerelease or a mismatched pair is not the form
|
||||
// (the tree receipt and gate reuse share this table, so a looser match would call a real change a stamp).
|
||||
expect(rewriteStamps("since: '1.2.3.0-beta'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps('since: "1.2.3.0-beta"', '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0\"", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps('since: 1.2.3.0-beta', '1.2.3.0', '1.2.4.0').count).toBe(1);
|
||||
});
|
||||
|
||||
test('rewriteStamps: (was X) accepts a matching quote pair and keeps it; a mismatched pair is not the form; a quoted vX already matched', () => {
|
||||
expect(rewriteStamps("(was '1.2.3.0')", '1.2.3.0', '1.2.4.0')).toEqual({ text: "(was '1.2.4.0')", count: 1 });
|
||||
expect(rewriteStamps('(was "1.2.3.0")', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was "1.2.4.0")', count: 1 });
|
||||
expect(rewriteStamps('(was 1.2.3.0)', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was 1.2.4.0)', count: 1 });
|
||||
expect(rewriteStamps("(was '1.2.3.0\")", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("'v1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "'v1.2.4.0'", count: 1 });
|
||||
// A bare pin equal to X is still not a stamp form, quoted or not.
|
||||
expect(rewriteStamps("pinned: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
});
|
||||
|
||||
test('rewriteStamps: a policy template that overlaps the built-in quoted form counts the mention once', () => {
|
||||
const r = rewriteStamps(" { since: '1.2.3.0', id: 'x', text: 't' },", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]);
|
||||
expect(r).toEqual({ text: " { since: '1.2.4.0', id: 'x', text: 't' },", count: 1 });
|
||||
});
|
||||
|
||||
test('tentativeMentions: quoted since: rows and template forms on branch-added lines are candidates like the unquoted ones', () => {
|
||||
const added = new Map([['src/notice.ts', new Set([
|
||||
" { since: '1.2.4.0', id: 'single', text: 't' },",
|
||||
' { since: "1.2.5.0", id: "double", text: "t" },',
|
||||
' since: 1.2.6.0',
|
||||
'<!-- x-tree-stamp: 1.2.7.0 -->',
|
||||
" pinned: '1.2.8.0'",
|
||||
])]]);
|
||||
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0']);
|
||||
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0', ['<!-- x-tree-stamp: {version} -->']).sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0', '1.2.7.0']);
|
||||
// The fork-version floor and the allocated version still apply to quoted mentions.
|
||||
expect(tentativeMentions(added, '1.2.5.0', '1.2.6.0').sort()).toEqual([]);
|
||||
// A quoted prerelease, a mismatched pair and an unclosed (was are not candidates; the bare prerelease still is.
|
||||
const odd = new Map([['x', new Set(["since: '1.2.4.0-rc'", 'since: "1.2.5.0\'', "(was '1.2.6.0'x", '(was 1.2.7.0-rc)', 'since: 1.2.8.0-rc'])]]);
|
||||
expect(tentativeMentions(odd, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.8.0']);
|
||||
});
|
||||
});
|
||||
|
||||
/** A gbrain-shaped fixture: a quoted-row source file in stamp_files, with and without the since: template. */
|
||||
const NOTICE = 'src/notice.ts';
|
||||
const row = (v: string, id: string) => ` { since: '${v}', id: '${id}', text: '${id}' },`;
|
||||
const notice = (rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n');
|
||||
function policy(templates: boolean) {
|
||||
return { ...BASE_POLICY, release_tool: null, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', NOTICE], ...(templates ? { stamp_patterns: ["since: '{version}'"] } : {}) };
|
||||
}
|
||||
function write(repo: FixtureRepo, rel: string, text: string): void {
|
||||
fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, rel), text);
|
||||
}
|
||||
/** The repo's own stamp on the branch, the way gbrain's release:restamp leaves it: VERSION and the rows at X. */
|
||||
function stampBranch(repo: FixtureRepo, v: string, rows: string[]): void {
|
||||
write(repo, 'VERSION', v + '\n');
|
||||
write(repo, NOTICE, notice(rows));
|
||||
repo.git('commit', '-qam', `chore: bump version (v${v})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reporter's state: the PR stamped 1.2.4.0 (VERSION and its row), another PR took the slot and the base
|
||||
* is 1.2.4.0 with its own quoted 1.2.4.0 row, the PR merged the base. VERSION equals the base's on both sides.
|
||||
*/
|
||||
function lostSlot(repo: FixtureRepo): void {
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
stampBranch(repo, '1.2.4.0', [row('1.2.3.0', 'base_row'), '', '', '', row('1.2.4.0', 'mine')]);
|
||||
// The other PR takes the slot through the real stamp: VERSION 1.2.4.0, its CHANGELOG entry headed, its own 1.2.4.0 row.
|
||||
makePr(repo, 'pr-other');
|
||||
write(repo, NOTICE, notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
|
||||
repo.git('commit', '-qam', 'other row');
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-qam', 'stamp other');
|
||||
mergeToMain(repo, 'pr-other');
|
||||
repo.git('checkout', '-q', 'pr-mine');
|
||||
mergeMain(repo);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
|
||||
expect(repo.git('show', 'origin/main:VERSION')).toBe('1.2.4.0');
|
||||
}
|
||||
|
||||
describe('a PR whose VERSION already equals the base (gbrain #7640)', () => {
|
||||
test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the PR\'s quoted row moves, the base\'s own quoted rows stay, and the stage says why', (_, templates) => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-', policy: policy(templates) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
const dry = restamp(repo, ['--next', '--dry-run']);
|
||||
expect(dry.status).toBe(EXIT.ok);
|
||||
expect(dry.out).toMatch(/^- \{ since: '1\.2\.4\.0', id: 'mine', text: 'mine' \},$/m);
|
||||
expect(dry.out).toMatch(/^\+ \{ since: '1\.2\.5\.0', id: 'mine', text: 'mine' \},$/m);
|
||||
expect(repo.git('status', '--porcelain')).toBe('');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('RESTAMP: 1.2.4.0 -> 1.2.5.0 (stamped;');
|
||||
const sp = stageDetail(r.out, 'stamp_paths')!;
|
||||
expect(sp[1]).toBe('done');
|
||||
expect(sp[2]).toBe(`1 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0); VERSION equals base 1.2.4.0, so 1.2.4.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
|
||||
// The base's rows are byte-identical to origin/main's: the 1.2.4.0 row is the base's release and never moved.
|
||||
expect(repo.git('show', `origin/main:${NOTICE}`) + '\n').toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
|
||||
const journal = JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json'));
|
||||
expect(journal.inputs).toMatchObject({ old_version: '1.2.4.0', new_version: '1.2.5.0' });
|
||||
expect(journal.stages.find((s: any) => s.name === 'stamp_paths').detail).toContain('1 moved, 1 kept');
|
||||
});
|
||||
|
||||
test('negative control: with no branch-added mention of the base version nothing moves, every base-side quoted row stays, and the stage says so', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-neg-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
// The PR adds a row of its own in another form entirely, plus an unrelated line in the stamp file.
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
|
||||
repo.git('commit', '-qam', 'my row');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
const sp = stageDetail(r.out, 'stamp_paths')!;
|
||||
expect(sp[1]).toBe('skipped');
|
||||
expect(sp[2]).toBe('no tentative mention newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)');
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
|
||||
expect(r.out).not.toMatch(/^[-+] \{ since: '1\.2\.3\.0'/m);
|
||||
});
|
||||
|
||||
test('a branch forked at the base version (fork equals base, nothing stamped) still moves its own base-version row and keeps the base\'s', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-fork-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]));
|
||||
write(repo, 'docs/pr-mine.md', 'Requires v1.2.3.0 or later.\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'my row');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 2 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (docs/pr-mine.md: 1 moved, 0 kept; src/notice.ts: 1 moved, 1 kept)');
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]));
|
||||
expect(readFile(repo, 'docs/pr-mine.md')).toBe('Requires v1.2.4.0 or later.\n');
|
||||
expect(readFile(repo, 'docs/notes.md')).toBe('Previous release v1.2.3.0.\nleft-pad pinned at 1.2.3 (was 1.2.2)\n');
|
||||
});
|
||||
|
||||
test('provenance is by position: a branch-added line whose text equals a base line moves alone, and the base copy stays', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-dup-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'dup')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.3.0', 'dup')]));
|
||||
repo.git('commit', '-qam', 'my row, same text as the base row');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe(`1 mention(s) in 1 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.4.0', 'dup')]));
|
||||
});
|
||||
|
||||
test('diff.interHunkContext joining two hunks around a base row still places each branch row by position', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-ihc-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([' { since: "0.0.0.0", id: "first_placeholder" },', row('1.2.3.0', 'base_row'), ' { since: "0.0.0.0", id: "second_placeholder" },']));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('config', 'diff.interHunkContext', '1');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.3.0', 'second')]));
|
||||
repo.git('commit', '-qam', 'two rows around the base row');
|
||||
// The joined hunk carries the base row as context between the two added lines.
|
||||
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toMatch(/\n\+ \{ since: '1\.2\.3\.0', id: 'first'.*\n \{ since: '1\.2\.3\.0', id: 'base_row'.*\n-.*\n\+ \{ since: '1\.2\.3\.0', id: 'second'/);
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 2 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.4.0', 'second')]));
|
||||
});
|
||||
|
||||
test('a CRLF checkout (core.autocrlf, eol=crlf) whose diff is LF-normalized still finds the branch-added row', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-crlf-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, '.gitattributes', `${NOTICE} text eol=crlf\n`);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('config', 'core.autocrlf', 'true');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]).replace(/\n/g, '\r\n'));
|
||||
repo.git('commit', '-qam', 'my row');
|
||||
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toContain("+ { since: '1.2.3.0', id: 'mine'");
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]).replace(/\n/g, '\r\n'));
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('a stamp interrupted after VERSION was written retries as a stamp, not a noop: the journal restores the equal-base provenance', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-retry-', policy: policy(true) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
const dir = path.join(repo.work, path.dirname(NOTICE));
|
||||
fs.chmodSync(dir, 0o555);
|
||||
try {
|
||||
const first = restamp(repo, ['--next']);
|
||||
expect(first.status).not.toBe(EXIT.ok);
|
||||
expect(first.err).toMatch(/EACCES|permission denied/i);
|
||||
} finally { fs.chmodSync(dir, 0o755); }
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
|
||||
expect(readFile(repo, NOTICE)).toContain(row('1.2.4.0', 'mine'));
|
||||
const again = restamp(repo, ['--next']);
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(again.out).toContain('RESTAMP: 1.2.5.0 -> 1.2.5.0 (stamped;');
|
||||
expect(stageDetail(again.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
|
||||
expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete');
|
||||
});
|
||||
|
||||
test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
const r = restamp(repo, ['--next', '--was', '1.2.4.0']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0)');
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.5.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,26 @@ describe('issue intent (pure)', () => {
|
||||
expect(mentionsIn('v1.2.3 (#301) release notes').map(x => x.issue)).toEqual([301]);
|
||||
});
|
||||
|
||||
// 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)", "(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']]);
|
||||
const links = resolveLinks(named, [], () => true);
|
||||
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', 'closing', 'addresses', 'fixing', 'implements', 'cf.', 'e.g.', 'i.e.', 'bug', 'upstream PR', 'v1.2', 'v1.2.3-rc1', '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]]);
|
||||
expect(mentionsIn('Fixes #12').map(x => [x.repo ?? '', x.issue, x.intent])).toEqual([['', 12, 'fixes']]);
|
||||
expect(mentionsIn('same line (gbrain #7625) and #13 here').map(x => [x.repo ?? '', x.issue])).toEqual([['gbrain', 7625], ['', 13]]);
|
||||
});
|
||||
|
||||
test('resolution: explicit --issue is closure, unreadable closure becomes Refs, a flake issue is never a plain Ref', () => {
|
||||
const links = resolveLinks(mentionsIn('Fixes #14\nFixes #15\nsee #40 and #16'), [16], n => n !== 15, [{ issue: 40, run: '7' }]);
|
||||
expect(links).toEqual({ fixes: [14, 16], refs: [15], flakes: [{ issue: 40, run: '7' }], cross: [], unreadable: [15] });
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Operational gaps seen on live trains, through the real
|
||||
* bin/gstack-ship-queue against the train fixture: the screen verdict is read
|
||||
* from the run (ready refuses a red head, verify --screen prints it, train
|
||||
* build refuses the pin), a lagging GitHub merge ref gets a scratch merge
|
||||
* (#3465), `train prune --stale` settles trains whose PRs merged elsewhere,
|
||||
* and `train watch` resumes from the record after an interruption.
|
||||
*/
|
||||
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 './helpers/restamp-fixture';
|
||||
import {
|
||||
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainJson, trainFixture, type Rule, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const fixtures: TrainFixture[] = [];
|
||||
const make = (policy: Record<string, unknown> = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; };
|
||||
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
||||
|
||||
const next = (out: string) => line(out, 'NEXT:');
|
||||
const result = (out: string) => line(out, 'GSTACK_RESULT:');
|
||||
|
||||
function readyFixture() {
|
||||
const fx = make();
|
||||
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 };
|
||||
}
|
||||
const receiptOf = (fx: TrainFixture, pr: string, head: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `${pr}-${head}.json`), 'utf8'));
|
||||
|
||||
const RED_SCREEN: Rule = { status: 'completed', conclusion: 'failure', plan: { planned: 3 }, binding: { verdict: 'RED', flaky: ['c-flaky'] }, outcomes: { red: ['judge-voice-tone'], flakyLines: ['c-flaky rule FLAKY: failed in slice 2, passed on its one rerun'] } };
|
||||
const GREEN_SCREEN: Rule = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, binding: { verdict: 'GREEN', flaky: ['c-flaky'] }, outcomes: { flakyLines: ['c-flaky rule FLAKY: failed in slice 2, passed on its one rerun'] } };
|
||||
|
||||
describe('the screen verdict comes from the run', () => {
|
||||
// Value: protects=a RED screen run (runs 40269992077 and 40275443393 on 2026-10-26 were VERDICT RED while the lane reported green) can never be READY'd, followed or seated;
|
||||
// fails_when=ready or train build trust the lane's choice without reading the run's gate binding; why_new=no test read a screen's verdict; seam=the gh shim's gate-binding and report-verdict artifacts; no_claim=evals.yml's real upload step
|
||||
test('a RED screen refuses ready under either choice and is never re-dispatched; verify --screen prints the red and flaky cases; train build refuses the pin', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = RED_SCREEN; });
|
||||
const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([first.status, line(first.out, 'SCREEN_DISPATCH:')]).toEqual([EXIT.ok, expect.stringMatching(/^SCREEN_DISPATCH: evals\.yml run 100 /)]);
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(again.status).toBe(EXIT.refused);
|
||||
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 RED red=judge-voice-tone flaky=c-flaky (verdict RED, reconcile exit 1, run concluded failure, flaky 1)');
|
||||
expect(line(again.out, 'FLAKY:')).toBe('FLAKY: c-flaky rule FLAKY: failed in slice 2, passed on its one rerun');
|
||||
expect(again.err).toMatch(/the screen recorded for [0-9a-f]{12} \(run 100\) is RED on judge-voice-tone; this head is not READY under any screen choice; fix: .*\(QUEUE_SCREEN_RED\)/);
|
||||
expect(next(again.out)).toBe('NEXT: repair, push a new head, run /ship, then gstack-ship-queue ready --pr 1 --screen');
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'RED', run: 100, red: ['judge-voice-tone'], flaky: ['c-flaky'] });
|
||||
const noScreen = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect([noScreen.status, noScreen.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_SCREEN_RED)')]);
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect(verify.status).toBe(EXIT.fail);
|
||||
expect(line(verify.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 RED red=judge-voice-tone flaky=c-flaky (verdict RED, reconcile exit 1, run concluded failure, flaky 1)');
|
||||
expect(verify.out.split('\n')[1]).toMatch(/^screen: screen policy=narrow nonce=pr\.screen\.narrow-\S+ run=100 verdict=RED$/);
|
||||
expect(result(verify.out)).toMatch(/status=refused run=.*1-/);
|
||||
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect(build.status).toBe(EXIT.refused);
|
||||
expect(build.err).toMatch(/#1's screen run 100 is RED on judge-voice-tone .*a red head is not seated.*\(QUEUE_SCREEN_RED\)/);
|
||||
expect(next(build.out)).toBe('NEXT: none');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains'))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test('a GREEN screen with flaky cases is followed, recorded and printed by verify --screen and train build; a run that bound no verdict is re-dispatched', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 GREEN flaky=c-flaky (verdict GREEN, reconcile exit 0, run concluded success, flaky 1)');
|
||||
expect(line(again.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 100 (same merge SHA, base and policy; no duplicate)');
|
||||
expect(again.out.split('\n')[1]).toMatch(/^screen: screen policy=narrow nonce=\S+ run=100 verdict=GREEN$/);
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'GREEN', run: 100, red: [], flaky: ['c-flaky'] });
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect([verify.status, verify.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(verify.out, 'FLAKY:')).toBe('FLAKY: c-flaky rule FLAKY: failed in slice 2, passed on its one rerun');
|
||||
expect(next(verify.out)).toBe(`NEXT: gstack-ship-queue train build --prs 1@${head},...`);
|
||||
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([build.status, line(build.out, 'SCREEN:'), line(build.out, 'ORDER:')]).toEqual([EXIT.ok, 'SCREEN: #1 run 100 GREEN (flaky: c-flaky)', 'ORDER: 1:#1(screened)']);
|
||||
fx.write(d => { delete d.artifacts['100']['gate-binding-a1']; });
|
||||
const unbound = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect(unbound.status).toBe(EXIT.refused);
|
||||
expect(unbound.err).toMatch(/has no verdict to read: run 100 concluded success and bound no verdict \(no gate-binding artifact.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(next(unbound.out)).toBe('NEXT: gstack-ship-queue ready --pr 1 --screen (dispatches a fresh screen)');
|
||||
const redispatch = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(redispatch.status).toBe(EXIT.ok);
|
||||
expect(line(redispatch.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 101 .*\(not following run 100 concluded success and bound no verdict .*: a screen that bound no verdict is not a screen\)$/);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, reuse: { run: 77, flaky: ['c-reused'] } }; });
|
||||
const reusedDispatch = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--broad', '--screen']);
|
||||
expect(reusedDispatch.status).toBe(EXIT.ok);
|
||||
const reused = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect([reused.status, line(reused.out, 'SCREEN_VERDICT:')]).toEqual([EXIT.ok, 'SCREEN_VERDICT: #1 run 102 GREEN flaky=c-reused (reused the GREEN verdict of run 77 (main-moved): same own diff on a moved main, flaky 1)']);
|
||||
const noRun = queue(fx, ['verify', '--pr', '2', '--screen']);
|
||||
expect(noRun.err).toMatch(/cannot read PR #2's head through gh.*\(QUEUE_PROOF_MISSING\)/);
|
||||
const unscreened = openPr(fx, '3');
|
||||
readyReceipt(fx, '3', unscreened);
|
||||
const none = queue(fx, ['verify', '--pr', '3', '--screen']);
|
||||
expect([none.status, line(none.out, 'SCREEN_VERDICT:')]).toEqual([EXIT.ok, 'SCREEN_VERDICT: #3 none (READY --no-screen)']);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a screen that concluded success with no slice (18 of 41 PR screens on 2026-10-26, superseded by a push before the plan) is NOT-RUN at verdict, not a screen and not a stale ref;
|
||||
// fails_when=verify --screen calls it GREEN, HOLLOW or QUEUE_SCREEN_STALE_REF, or ready follows it; why_new=the hollow outcome reused the stale-ref code; seam=the gh shim's run with no plan artifact; no_claim=why evals.yml planned nothing
|
||||
// Value: protects=train build --dry-run (with or without --stack-tests) writes nothing: the screen verdict it reads is printed, not recorded in the READY receipt or pushed to refs/notes/gstack-trains, as its DRY-RUN line says;
|
||||
// fails_when=admitSlots persists the read verdict under --dry-run (the notes ref moves and the receipt gains a verdict before any real build); why_new=the other dry-run tests only check the trains dir; seam=none; no_claim=a verdict that was already recorded by ready or verify
|
||||
test('train build --dry-run prints the screen verdict it read without recording it in the receipt or pushing the notes ref', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toBeUndefined();
|
||||
const notes = fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains');
|
||||
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([build.status, line(build.out, 'SCREEN:'), line(build.out, 'DRY-RUN:')]).toEqual([EXIT.ok, 'SCREEN: #1 run 100 GREEN (flaky: c-flaky)', 'DRY-RUN: nothing written, pushed or dispatched']);
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toBeUndefined();
|
||||
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).toBe(notes);
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect([verify.status, receiptOf(fx, '1', head).screen.verdict?.outcome]).toEqual([EXIT.ok, 'GREEN']);
|
||||
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe(notes);
|
||||
}, 120_000);
|
||||
|
||||
test('a screen run that concluded success with no slice is NOT-RUN: verify --screen refuses with QUEUE_SCREEN_NOT_RUN, the receipt records NOT_RUN, and ready --screen dispatches afresh', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; });
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect(verify.status).toBe(EXIT.refused);
|
||||
expect(line(verify.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 NOT-RUN (run 100 planned no cases (no paid-plan artifact; concluded success))');
|
||||
expect(verify.err).toMatch(/the screen of #1 \(run 100\) concluded success with no slice: run 100 planned no cases .*; it is not a screen, and this head is not screened; fix: .*\(QUEUE_SCREEN_NOT_RUN\)/);
|
||||
expect(next(verify.out)).toBe('NEXT: gstack-ship-queue ready --pr 1 --screen (dispatches a fresh screen)');
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'NOT_RUN', run: 100 });
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([again.status, dispatches(fx, 'evals.yml').length]).toEqual([EXIT.ok, 2]);
|
||||
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 NOT-RUN (run 100 planned no cases (no paid-plan artifact; concluded success))');
|
||||
expect(line(again.out, 'SCREEN_DISPATCH:')).toMatch(/not following run 100 planned no cases .*: a screen that planned nothing is not a screen\)$/);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('#3465: the screen is planned on a scratch merge when GitHub\'s merge ref lags', () => {
|
||||
// Value: protects=a CHANGELOG-only conflict (every main stamp leaves one) no longer blocks --screen: the screen tests the head merged onto current main by the train's rules;
|
||||
// fails_when=ready still refuses on the lagging ref, pushes nothing, or the scratch merge's parents are not (base, head); why_new=the stale-ref test only asserted the refusal; seam=none; no_claim=evals.yml's checkout of the pushed branch
|
||||
test('a lagging or missing merge ref gets a scratch merge pushed as screen/<pr>-<head12>; a real conflict is QUEUE_SCREEN_STALE_REF; prune --stale removes the branch once the PR merged', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const { repo } = fx;
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
|
||||
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
|
||||
const cl = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- another PR, stamped\n'));
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
repo.git('checkout', '-q', 'pr-1');
|
||||
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
|
||||
expect([dry.status, dry.err]).toEqual([EXIT.ok, '']);
|
||||
const main = repo.git('rev-parse', 'origin/main');
|
||||
expect(line(dry.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${head.slice(0, 12)} onto main@${main.slice(0, 12)} \\(refs/pull/1/merge [0-9a-f]{12} merges onto [0-9a-f]{12}, not main@${main.slice(0, 12)}\\); would push screen/1-${head.slice(0, 12)} \\(dry run\\)$`));
|
||||
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
|
||||
const real = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([real.status, real.err]).toEqual([EXIT.ok, '']);
|
||||
const branch = `screen/1-${head.slice(0, 12)}`;
|
||||
expect(line(real.out, 'SCREEN_MERGE:')).toContain(`; pushed ${branch}`);
|
||||
const scratch = repo.git('ls-remote', 'origin', `refs/heads/${branch}`).split(/\s/)[0]!;
|
||||
expect(scratch).toMatch(/^[0-9a-f]{40}$/);
|
||||
repo.git('fetch', '-q', 'origin', `refs/heads/${branch}`);
|
||||
expect([repo.git('rev-parse', `${scratch}^1`), repo.git('rev-parse', `${scratch}^2`)]).toEqual([repo.git('rev-parse', 'origin/main'), head]);
|
||||
expect(repo.git('show', `${scratch}:VERSION`)).toBe('1.2.4.0');
|
||||
expect(repo.git('show', `${scratch}:CHANGELOG.md`)).toContain('- #1 change');
|
||||
expect(repo.git('show', `${scratch}:CHANGELOG.md`)).toContain('- another PR, stamped');
|
||||
const d = dispatches(fx, 'evals.yml');
|
||||
expect(d.length).toBe(1);
|
||||
expect((d[0]!.inputs as any)).toMatchObject({ screen_merge_sha: scratch, expected_sha: scratch, screen_pr: '1' });
|
||||
expect(receiptOf(fx, '1', head).screen).toMatchObject({ choice: 'screen', merge_sha: scratch, branch });
|
||||
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([missing.status, line(missing.out, 'SCREEN_DISPATCH:')]).toEqual([EXIT.ok, 'SCREEN_DISPATCH: following run 100 (same merge SHA, base and policy; no duplicate)']);
|
||||
expect(line(missing.out, 'SCREEN_MERGE:')).toContain('(origin has no refs/pull/1/merge)');
|
||||
const kept = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(kept.out, 'KEEP:')).toBe(`KEEP: ${branch} (#1 OPEN)`);
|
||||
fx.write(data => { (data.prs['1'] as any).state = 'MERGED'; });
|
||||
const pruned = queue(fx, ['train', 'prune', '--stale']);
|
||||
expect(line(pruned.out, 'DELETED:')).toBe(`DELETED: ${branch} (#1 MERGED)`);
|
||||
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
|
||||
fx.write(data => { (data.prs['1'] as any).state = 'OPEN'; });
|
||||
repo.git('checkout', '-q', '-B', 'mainsim2', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'pr-1.txt'), 'main added the same file\n');
|
||||
repo.git('add', 'pr-1.txt');
|
||||
repo.git('commit', '-qm', 'main adds pr-1.txt');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
repo.git('checkout', '-q', 'pr-1');
|
||||
const conflict = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(conflict.status).toBe(EXIT.refused);
|
||||
expect(conflict.err).toMatch(/#1 conflicts with main@[0-9a-f]{12} beyond the CHANGELOG and the stamp files \(pr-1\.txt\); GitHub's merge ref lags too \(origin has no refs\/pull\/1\/merge\).*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(next(conflict.out)).toBe('NEXT: resolve the conflict with main, push, run /ship, then gstack-ship-queue ready --pr 1 --screen');
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('train prune --stale and train watch', () => {
|
||||
function greenTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) {
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2');
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
fx.write(d => { d.rules['evals.yml'] = gate; });
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--free-timeout', '1']);
|
||||
return { h1, h2, r, nonce: nonceOf(r.out) };
|
||||
}
|
||||
|
||||
// Value: protects=a merging or green train whose PRs all merged elsewhere (gate.train-23af94b3c5e9 and -6755ab829acf on 2026-10-26) can be pruned without hand-editing refs/notes/gstack-trains;
|
||||
// fails_when=prune skips merging/green trains, prunes one with an OPEN PR, or prunes without --stale; why_new=prune only knew terminal states; seam=the gh shim's PR states; no_claim=GitHub's real PR state
|
||||
test('--stale settles a merging or green train whose every PR is MERGED or CLOSED on GitHub, logging each PR; an OPEN PR keeps it; without --stale nothing changes', () => {
|
||||
const fx = make();
|
||||
const { nonce } = greenTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
expect(trainJson(fx, nonce).state).toBe('green');
|
||||
const plain = queue(fx, ['train', 'prune']);
|
||||
expect(line(plain.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green)`);
|
||||
const open = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(open.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green; #1 OPEN, #2 OPEN still need the train)`);
|
||||
fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; });
|
||||
const half = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(half.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green; #2 OPEN still needs the train)`);
|
||||
fx.write(d => { (d.prs['2'] as any).state = 'CLOSED'; });
|
||||
const dry = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(dry.out, 'STALE:')).toBe(`STALE: ${nonce} (green): #1 MERGED, #2 CLOSED; would mark stale`);
|
||||
expect(line(dry.out, 'WOULD-DELETE:')).toBe(`WOULD-DELETE: train/${nonce} (stale)`);
|
||||
expect(trainJson(fx, nonce).state).toBe('green');
|
||||
const pruned = queue(fx, ['train', 'prune', '--stale']);
|
||||
expect([pruned.status, line(pruned.out, 'STALE:'), line(pruned.out, 'DELETED:')]).toEqual([EXIT.ok, `STALE: ${nonce} (green): #1 MERGED, #2 CLOSED`, `DELETED: train/${nonce} (stale)`]);
|
||||
const t = trainJson(fx, nonce);
|
||||
expect(t.state).toBe('stale');
|
||||
expect(t.effects.map((e: any) => e.what)).toContain('marked stale by train prune --stale: #1 MERGED, #2 CLOSED');
|
||||
expect(fx.repo.git('ls-remote', 'origin', `refs/heads/train/${nonce}`)).toBe('');
|
||||
const status = queue(fx, ['train', 'status']);
|
||||
expect(status.out).toContain('TRAIN: none live');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a stale train record or READY receipt leaves refs/notes/gstack-trains through one command under an exact lease, with the local files, instead of a hand-run git notes remove on every machine;
|
||||
// fails_when=forget removes the wrong notes, deletes locally when the push failed, or forgets a merging train; why_new=no CLI removed notes (merge-train skill, known gaps); seam=none; no_claim=a concurrent writer's race (the lease is exercised only as a fast-forward)
|
||||
test('train forget removes a settled train (attempts included) or a READY receipt from the notes ref and the state root, leaves other records alone, keeps everything (the local notes ref too) when the lease is rejected, and refuses a train still building, running or merging', () => {
|
||||
const fx = make();
|
||||
const { nonce, h1 } = greenTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const trainDir = path.join(fx.state, 'ship-queue', 'trains', nonce);
|
||||
expect(fs.existsSync(path.join(trainDir, 'attempts', 'a1.json'))).toBe(true);
|
||||
const dry = queue(fx, ['train', 'forget', '--train', nonce, '--dry-run']);
|
||||
expect([dry.status, line(dry.out, 'FORGET:')]).toEqual([EXIT.ok, `FORGET: train ${nonce} (green; 1 attempt(s))`]);
|
||||
expect(dry.out).toContain(` note gstack-trains:train:${nonce}`);
|
||||
expect(dry.out).toContain(` note gstack-trains:attempt:${nonce}:a1`);
|
||||
expect(fs.existsSync(trainDir)).toBe(true);
|
||||
const notesBefore = fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains').split(/\s/)[0]!;
|
||||
const forgot = queue(fx, ['train', 'forget', '--train', nonce]);
|
||||
expect([forgot.status, forgot.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(forgot.out, 'FORGOTTEN:')).toMatch(new RegExp(`^FORGOTTEN: 2 note\\(s\\) removed; notes pushed to origin refs/notes/gstack-trains \\(lease ${notesBefore.slice(0, 12)}\\); 1 local path\\(s\\) deleted$`));
|
||||
expect(fs.existsSync(trainDir)).toBe(false);
|
||||
const clean = path.join(fx.repo.root, 'clean-state');
|
||||
const status = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: clean });
|
||||
expect(status.out).toContain('TRAIN: none live');
|
||||
expect(fs.existsSync(path.join(clean, 'ship-queue', 'trains', nonce))).toBe(false);
|
||||
expect(fs.existsSync(path.join(clean, 'ship-queue', 'ready', repoKey(fx), `1-${h1}.json`))).toBe(false);
|
||||
const again = queue(fx, ['train', 'forget', '--train', nonce]);
|
||||
expect(again.err).toMatch(new RegExp(`no train record ${nonce} here or in refs/notes/gstack-trains.*\\(QUEUE_PROOF_MISSING\\)`));
|
||||
const h3 = openPr(fx, '3');
|
||||
fx.write(d => { d.ci[h3] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const pregate = path.join(fx.repo.root, 'pregate3.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h3, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
expect(queue(fx, ['ready', '--pr', '3', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
|
||||
const h4 = openPr(fx, '4');
|
||||
fx.write(d => { d.ci[h4] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h4, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
expect(queue(fx, ['ready', '--pr', '4', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
|
||||
const receipt = queue(fx, ['train', 'forget', '--receipt', `3@${h3}`]);
|
||||
expect([receipt.status, line(receipt.out, 'FORGET:')]).toEqual([EXIT.ok, `FORGET: READY receipt #3 at ${h3.slice(0, 12)} (screen no-screen)`]);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `3-${h3}.json`))).toBe(false);
|
||||
const rebuilt = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: path.join(fx.repo.root, 'clean-state-2') });
|
||||
expect(rebuilt.status).toBe(EXIT.ok);
|
||||
expect(fs.existsSync(path.join(fx.repo.root, 'clean-state-2', 'ship-queue', 'ready', repoKey(fx), `4-${h4}.json`))).toBe(true);
|
||||
expect(fs.existsSync(path.join(fx.repo.root, 'clean-state-2', 'ship-queue', 'ready', repoKey(fx), `3-${h3}.json`))).toBe(false);
|
||||
expect(queue(fx, ['train', 'forget', '--receipt', '3@abc']).err).toMatch(/--receipt takes <pr>@<full head sha>.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(queue(fx, ['train', 'forget']).status).toBe(EXIT.usage);
|
||||
const { nonce: second } = (() => { readyReceipt(fx, '3', h3); readyReceipt(fx, '4', h4); const r = queue(fx, ['train', 'build', '--prs', `3@${h3},4@${h4}`, '--free-timeout', '1']); return { nonce: nonceOf(r.out) }; })();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', second]).status).toBe(EXIT.ok);
|
||||
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
|
||||
fs.mkdirSync(path.dirname(hook), { recursive: true });
|
||||
fs.writeFileSync(hook, '#!/bin/sh\nwhile read o n r; do case "$r" in refs/notes/*) echo "notes frozen" >&2; exit 1;; esac; done\nexit 0\n', { mode: 0o755 });
|
||||
const localNotes = fx.repo.git('rev-parse', 'refs/notes/gstack-trains');
|
||||
const rejected = queue(fx, ['train', 'forget', '--train', second]);
|
||||
expect(rejected.status).toBe(EXIT.refused);
|
||||
expect(rejected.err).toMatch(/the notes ref was not updated \(git push refs\/notes\/gstack-trains rejected: .*notes frozen.*; the local notes ref was restored\); nothing deleted locally.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains', second, 'train.json'))).toBe(true);
|
||||
expect(fx.repo.git('rev-parse', 'refs/notes/gstack-trains')).toBe(localNotes);
|
||||
expect(fx.repo.git('notes', '--ref=refs/notes/gstack-trains', 'list').split('\n').length).toBeGreaterThan(0);
|
||||
fs.rmSync(hook);
|
||||
const trainFile = path.join(fx.state, 'ship-queue', 'trains', second, 'train.json');
|
||||
for (const state of ['dispatch-unknown', 'merging']) {
|
||||
const t = JSON.parse(fs.readFileSync(trainFile, 'utf8'));
|
||||
t.state = state;
|
||||
fs.writeFileSync(trainFile, JSON.stringify(t));
|
||||
const live = queue(fx, ['train', 'forget', '--train', second]);
|
||||
expect(live.status).toBe(EXIT.refused);
|
||||
expect(live.err).toMatch(new RegExp(`is ${state}; forget settles nothing that is still building, running or merging .*\\(QUEUE_PROOF_MISSING\\)`));
|
||||
expect(fs.existsSync(trainFile)).toBe(true);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a restarted controller re-arms with one command: watch continues from the record (free tests, gate dispatch, the pending run, the verdict) and prints NEXT;
|
||||
// fails_when=watch dispatches twice, blocks past --timeout, silently replaces a malformed --free-timeout with the default, or does not reach the verdict after a restart; why_new=no resumable follow existed (hand-run gh run watch chains died with the machine); seam=GSTACK_SHIP_QUEUE_POLL_MS; no_claim=real GitHub run timing
|
||||
test('watch resumes from free-pending through the gate dispatch to the verdict, times out with its own NEXT, and never dispatches twice', () => {
|
||||
const fx = make();
|
||||
fx.write(d => { d.rules['free-tests.yml'] = { status: 'in_progress', conclusion: null }; });
|
||||
const { nonce, r } = greenTrain(fx, { ...GREEN_GATE, status: 'in_progress', conclusion: null });
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(trainJson(fx, nonce).state).toBe('free-pending');
|
||||
const waiting = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
|
||||
expect([waiting.status, result(waiting.out)]).toEqual([EXIT.refused, expect.stringContaining('status=incomplete')]);
|
||||
expect(waiting.out).toContain(`WATCH: train ${nonce} state=free-pending (resumable; rerun this command after an interruption)`);
|
||||
expect(next(waiting.out)).toBe(`NEXT: gstack-ship-queue train watch --train ${nonce} --timeout 1`);
|
||||
expect(dispatches(fx, 'evals.yml')).toEqual([]);
|
||||
fx.write(d => { for (const run of d.runs) if (run.workflow === 'free-tests.yml') Object.assign(run, { status: 'completed', conclusion: 'success' }); });
|
||||
const gate = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
|
||||
expect(gate.status).toBe(EXIT.refused);
|
||||
expect(gate.out).toContain(`WATCH: train build --train ${nonce}: free tests green, gate dispatched`);
|
||||
expect(gate.err).toMatch(/is still in_progress at the watch timeout.*\(QUEUE_TRAIN_PENDING\)/);
|
||||
expect(next(gate.out)).toBe(`NEXT: gstack-ship-queue train watch --train ${nonce} --timeout 1`);
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
expect(trainJson(fx, nonce).state).toBe('gate-pending');
|
||||
const again = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
|
||||
expect([again.status, dispatches(fx, 'evals.yml').length]).toEqual([EXIT.refused, 1]);
|
||||
fx.write(d => { for (const run of d.runs) if (run.workflow === 'evals.yml') Object.assign(run, { status: 'completed', conclusion: 'success' }); });
|
||||
const done = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '5']);
|
||||
expect([done.status, done.err]).toEqual([EXIT.ok, '']);
|
||||
expect(done.out).toContain('WATCH: gate run completed; reading the verdict');
|
||||
expect(line(done.out, 'VERDICT:')).toMatch(new RegExp(`^VERDICT: GREEN train ${nonce} run 10[0-9] `));
|
||||
expect(next(done.out)).toBe(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce}`);
|
||||
const idle = queue(fx, ['train', 'watch', '--train', nonce]);
|
||||
expect([idle.status, line(idle.out, 'WATCH: nothing')]).toEqual([EXIT.ok, 'WATCH: nothing to wait for in state green']);
|
||||
expect(next(idle.out)).toBe(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce}`);
|
||||
expect(queue(fx, ['train', 'watch']).status).toBe(EXIT.usage);
|
||||
expect(queue(fx, ['train', 'watch', '--train', nonce, '--free-timeout', 'abc']).status).toBe(EXIT.usage);
|
||||
expect(queue(fx, ['train', 'watch', '--train', 'no-such-train']).err).toMatch(/no train record no-such-train.*\(QUEUE_PROOF_MISSING\)/);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('verify --post-merge', () => {
|
||||
// Value: protects=after a train merges, the coordinator watches main's push workflows at the merged sha until they settle instead of a background gh chain a restart kills (GBrain practice 8);
|
||||
// fails_when=a pending check is called settled, a red check is called green, a pending or failed commit status (the legacy status API, which ciAtHead counts) is reported as red with no name or as green, or the command needs process state; why_new=no post-merge watch existed; seam=the gh shim's check runs and commit statuses at a sha; no_claim=which workflows main runs
|
||||
test('waits for every check run at the sha to complete, prints each, is green only when none failed, red with the failed names, pending or red on a commit status, and resumable after a timeout', () => {
|
||||
const fx = make();
|
||||
const sha = fx.repo.git('rev-parse', 'HEAD');
|
||||
fx.write(d => { d.ci[sha] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }, { name: 'windows-free-tests', status: 'in_progress', conclusion: null }, { name: 'main-backstop', status: 'completed', conclusion: 'neutral' }]; });
|
||||
const pending = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect(pending.status).toBe(EXIT.refused);
|
||||
expect(line(pending.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} pending (passed=1 failed=0 pending=1 skipped=1)`);
|
||||
expect(pending.out).toContain('CHECK: windows-free-tests in_progress');
|
||||
expect(pending.err).toMatch(new RegExp(`main's push workflows at ${sha.slice(0, 12)} have not settled .*windows-free-tests.*\\(QUEUE_TRAIN_PENDING\\)`));
|
||||
expect(next(pending.out)).toBe(`NEXT: gstack-ship-queue verify --post-merge ${sha} --timeout 1`);
|
||||
fx.write(d => { d.ci[sha]![1] = { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }; });
|
||||
const red = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect(red.status).toBe(EXIT.fail);
|
||||
expect(line(red.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} red (passed=1 failed=1 pending=0 skipped=1)`);
|
||||
expect(red.out).toContain('CHECK: windows-free-tests failure');
|
||||
expect(red.err).toMatch(new RegExp(`main's push workflows at ${sha.slice(0, 12)} are red: windows-free-tests.*\\(QUEUE_POST_MERGE_RED\\)`));
|
||||
expect(next(red.out)).toBe('NEXT: read the red run, repair on a new PR or roll back; gstack-ship-queue train status names the freeze if the backstop filed one');
|
||||
fx.write(d => { d.ci[sha]![1] = { name: 'windows-free-tests', status: 'completed', conclusion: 'success' }; });
|
||||
const green = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([green.status, green.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(green.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} green (passed=2 failed=0 pending=0 skipped=1)`);
|
||||
expect(green.out.split('\n').filter(l => l.startsWith('CHECK: ')).sort()).toEqual(['CHECK: free-tests success', 'CHECK: main-backstop neutral', 'CHECK: windows-free-tests success']);
|
||||
expect(next(green.out)).toBe('NEXT: none');
|
||||
fx.write(d => { d.statuses = { [sha]: [{ state: 'pending', context: 'ci/vendor' }] }; });
|
||||
const statusPending = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([statusPending.status, line(statusPending.out, 'POST_MERGE:')]).toEqual([EXIT.refused, `POST_MERGE: ${sha.slice(0, 12)} pending (passed=2 failed=0 pending=1 skipped=1)`]);
|
||||
expect(statusPending.err).toMatch(/have not settled at the timeout: a commit status still running.*\(QUEUE_TRAIN_PENDING\)/);
|
||||
fx.write(d => { d.statuses = { [sha]: [{ state: 'failure', context: 'ci/vendor' }] }; });
|
||||
const statusRed = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([statusRed.status, line(statusRed.out, 'POST_MERGE:')]).toEqual([EXIT.fail, `POST_MERGE: ${sha.slice(0, 12)} red (passed=2 failed=1 pending=0 skipped=1)`]);
|
||||
expect(statusRed.err).toMatch(/are red: a commit status \(no check run failed\).*\(QUEUE_POST_MERGE_RED\)/);
|
||||
fx.write(d => { d.statuses = {}; d.ci[sha] = []; });
|
||||
const none = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([none.status, line(none.out, 'POST_MERGE:')]).toEqual([EXIT.refused, `POST_MERGE: ${sha.slice(0, 12)} none (passed=0 failed=0 pending=0 skipped=0)`]);
|
||||
expect(none.err).toMatch(new RegExp(`no check run has attached to ${sha.slice(0, 12)} yet.*\\(QUEUE_TRAIN_PENDING\\)`));
|
||||
expect(queue(fx, ['verify', '--post-merge', 'not-a-sha']).status).toBe(EXIT.usage);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('ready --dispatch-ci', () => {
|
||||
// Value: protects=a PR that conflicts with the base (a CHANGELOG-only conflict after every stamp) gets its free workflows dispatched on the branch, waited for, and its requires-remote pregate lanes cleared with the run URLs in one command, replacing the four-step hand procedure;
|
||||
// fails_when=the dispatch happens on a fork head, a red run clears a lane, a lane whose workflow was not dispatched is cleared, ready still says ci: none, or the CI_DISPATCH lines push the screen: line in front of READY (identifiers first); why_new=the ci-hint was advisory prose; seam=the gh shim's workflow run, run list and check runs; no_claim=GitHub's real dispatch latency
|
||||
test('dispatches the named workflows on a conflicting PR, waits for their runs, clears the matching pregate lanes with the run URLs and then READYs; a fork head and a red run are refused', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => {
|
||||
(d.prs['1'] as any).mergeable = 'CONFLICTING';
|
||||
d.ci[head] = [];
|
||||
d.rules['free-tests.yml'] = { ci: 'free-tests', headSha: head };
|
||||
d.rules['windows-free-tests.yml'] = { ci: 'windows-free-tests', headSha: head };
|
||||
});
|
||||
const tree = JSON.parse(fs.readFileSync(pregate, 'utf8')).tree;
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'requires-remote', checks: [{ id: 'unit', status: 'pass' }], requires_remote: [
|
||||
{ lane: 'free-tests.yml/free-tests', platform: 'linux', workflow: 'free-tests.yml', job: 'free-tests', tests: ['test/a.test.ts'], receipt: null },
|
||||
{ lane: 'windows-free-tests.yml/windows-free-tests', platform: 'windows', workflow: 'windows-free-tests.yml', job: 'windows-free-tests', tests: ['test/a.test.ts'], receipt: null },
|
||||
{ lane: 'macos-free-tests.yml/macos', platform: 'macos', workflow: 'macos-free-tests.yml', job: 'macos', tests: ['test/b.test.ts'], receipt: null }] }));
|
||||
const plain = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect([plain.status, line(plain.out, 'ci:')]).toEqual([EXIT.refused, `ci: none at ${head.slice(0, 12)} (passed=0 failed=0 pending=0 skipped=0)`]);
|
||||
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml,windows-free-tests.yml', '--timeout', '5']);
|
||||
expect(r.out.split('\n').filter(l => l.startsWith('CI_DISPATCH:'))).toEqual([
|
||||
'CI_DISPATCH: free-tests.yml --ref pr-1 run 100 https://example.invalid/runs/100 success',
|
||||
'CI_DISPATCH: windows-free-tests.yml --ref pr-1 run 101 https://example.invalid/runs/101 success']);
|
||||
expect(r.out.split('\n').filter(l => l.startsWith('PREGATE_CLEARED:'))).toEqual([
|
||||
'PREGATE_CLEARED: free-tests.yml/free-tests https://example.invalid/runs/100',
|
||||
'PREGATE_CLEARED: windows-free-tests.yml/windows-free-tests https://example.invalid/runs/101']);
|
||||
expect(line(r.out, 'ci:')).toBe(`ci: green at ${head.slice(0, 12)} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
expect(line(r.out, 'PREGATE:')).toMatch(/^PREGATE: requires-remote .*/);
|
||||
expect(r.err).toMatch(/1 requires-remote obligation\(s\) without a receipt: macos-free-tests\.yml\/macos.*\(QUEUE_NOT_READY\)/);
|
||||
const saved = JSON.parse(fs.readFileSync(pregate, 'utf8'));
|
||||
expect(saved.requires_remote.map((l: any) => l.receipt)).toEqual(['https://example.invalid/runs/100', 'https://example.invalid/runs/101', null]);
|
||||
expect(dispatches(fx).map(l => l.workflow)).toEqual(['free-tests.yml', 'windows-free-tests.yml']);
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
|
||||
expect(dispatches(fx).length).toBe(2);
|
||||
expect(line(again.out, 'CI_DISPATCH:')).toBe('CI_DISPATCH: free-tests.yml not dispatched (run 100 already concluded success at this head)');
|
||||
const cleared = JSON.parse(fs.readFileSync(pregate, 'utf8'));
|
||||
cleared.requires_remote[2].receipt = 'https://example.invalid/runs/7';
|
||||
fs.writeFileSync(pregate, JSON.stringify(cleared));
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
|
||||
expect([ok.status, ok.err]).toEqual([EXIT.ok, '']);
|
||||
const okLines = ok.out.split('\n');
|
||||
expect(okLines[0]).toBe('CI_DISPATCH: free-tests.yml not dispatched (run 100 already concluded success at this head)');
|
||||
expect(okLines[okLines.findIndex(l => l.startsWith('READY #1 ')) + 1]).toBe('screen: no-screen');
|
||||
fx.write(d => { d.rules['free-tests.yml'] = { ci: 'free-tests', conclusion: 'failure', headSha: head }; d.ci[head] = []; d.runs.length = 0; });
|
||||
const red = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
|
||||
expect([red.status, line(red.out, 'CI_DISPATCH:')]).toEqual([EXIT.refused, 'CI_DISPATCH: free-tests.yml --ref pr-1 run 102 https://example.invalid/runs/102 failure']);
|
||||
expect(red.err).toMatch(new RegExp(`free-tests\\.yml run 102 concluded failure at ${head.slice(0, 12)}; a red run clears no lane.*\\(QUEUE_NOT_READY\\)`));
|
||||
expect(JSON.parse(fs.readFileSync(pregate, 'utf8')).requires_remote[0].receipt).toBe('https://example.invalid/runs/100');
|
||||
fx.write(d => { (d.prs['1'] as any).isCrossRepository = true; });
|
||||
const fork = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml']);
|
||||
expect([fork.status, fork.err]).toEqual([EXIT.refused, expect.stringMatching(/#1's head is on a fork.*not dispatched.*\(QUEUE_TRAIN_UNTRUSTED\)/)]);
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', '../evil.yml']).status).toBe(EXIT.usage);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('train build --dry-run --stack-tests', () => {
|
||||
// Value: protects=a PR whose new tests pass on its own branch but fail once stacked behind another PR's change is caught before the train spends a gate (#3460 on #3451, 2026-10-26);
|
||||
// fails_when=the new test files run against the PR head instead of the integrated prefix, a failing prefix still dry-runs green, or a PR without new tests (a helper under test/ included: bun exits 1 when a positional filter matches no test file) is misreported; why_new=no pre-dispatch stack check existed; seam=bun test in a scratch worktree; no_claim=the repo's full suite on the prefix
|
||||
test('runs each PR\'s new test files on its integrated prefix T<k>: a pass and a helper-only PR are reported, a failure refuses the dry run and names the prefix, and the ordinary dry run skips it', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1', { file: 'test/pr-1_test.ts', text: "import { expect, test } from 'bun:test';\ntest('pr-1', () => { expect(1).toBe(1); });\n" });
|
||||
const h2 = openPr(fx, '2', { file: 'test/pr-2.test.ts', text: "import { existsSync } from 'node:fs';\nimport { expect, test } from 'bun:test';\ntest('pr-2 wants no pr-1 test beside it', () => { expect(existsSync('test/pr-1_test.ts')).toBe(false); });\n" });
|
||||
const h3 = openPr(fx, '3', { file: 'test/helpers/pr-3-helper.ts', text: 'export const three = 3;\n' });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
readyReceipt(fx, '3', h3);
|
||||
const plain = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--dry-run']);
|
||||
expect([plain.status, plain.out.includes('STACK_TESTS:')]).toEqual([EXIT.ok, false]);
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--dry-run', '--stack-tests']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out.split('\n').filter(l => l.startsWith('STACK_TESTS:'))).toEqual([
|
||||
'STACK_TESTS: #1 T1 1 new test file(s) pass (test/pr-1_test.ts)',
|
||||
expect.stringMatching(/^STACK_TESTS: #2 T2 1 new test file\(s\) FAIL \(test\/pr-2\.test\.ts\): .*\(fail\) pr-2 wants no pr-1 test beside it.*1 fail$/),
|
||||
'STACK_TESTS: #3 T3 no new test files']);
|
||||
expect(r.err).toMatch(/#2's new tests fail on the integrated prefix T2 \(test\/pr-2\.test\.ts\); they pass on the PR head alone only if nothing before it in the train changes what they read.*\(QUEUE_TRAIN_STACK_RED\)/);
|
||||
expect(next(r.out)).toBe(`NEXT: fix #2's tests against T2, or reorder with --order, then gstack-ship-queue train build --prs 1@${h1},2@${h2},3@${h3} --dry-run --stack-tests`);
|
||||
expect(line(r.out, 'DRY-RUN:')).toBe('DRY-RUN: nothing written, pushed or dispatched');
|
||||
const reordered = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--order', '2,1,3', '--dry-run', '--stack-tests']);
|
||||
expect(reordered.status).toBe(EXIT.ok);
|
||||
expect(reordered.out.split('\n').filter(l => l.startsWith('STACK_TESTS:'))).toEqual([
|
||||
'STACK_TESTS: #2 T1 1 new test file(s) pass (test/pr-2.test.ts)',
|
||||
'STACK_TESTS: #1 T2 1 new test file(s) pass (test/pr-1_test.ts)',
|
||||
'STACK_TESTS: #3 T3 no new test files']);
|
||||
expect(fx.repo.git('worktree', 'list', '--porcelain')).not.toContain('gstack-stack-tests-');
|
||||
expect(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--stack-tests']).status).toBe(EXIT.usage);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -196,9 +196,9 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--broad']).status).toBe(EXIT.usage);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success);
|
||||
// fails_when=the merge ref's second parent is not compared with the PR head, or a missing merge ref keeps the old QUEUE_NOT_READY code; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
|
||||
test('--screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge lags the PR head or is missing, dispatching nothing; a recomputed merge ref admits it', () => {
|
||||
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success): a lagging, missing or unfetchable ref is replaced by a scratch merge of the head onto current main (#3465), and an unreachable remote is still refused;
|
||||
// fails_when=the merge ref's parents are not compared with the PR head and current main, or the scratch merge is skipped; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
|
||||
test('--screen plans the screen on a scratch merge when refs/pull/<n>/merge lags the PR head, is missing or cannot be fetched; an unreachable remote is POLICY_SOURCE_UNAVAILABLE; a recomputed merge ref is used as is', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const { repo } = fx;
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-1.txt'), 'pushed after the merge ref was computed\n');
|
||||
@@ -208,41 +208,41 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
fx.write(d => { (d.prs['1'] as any).headRefOid = moved; (d.prs['1'] as any).body = shippedBody('1', moved); d.ci[moved] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const pregate2 = path.join(repo.root, 'pregate2.json');
|
||||
fs.writeFileSync(pregate2, JSON.stringify({ schema_version: 1, tree: runBin(repo, 'gstack-wtree', []).out.trim(), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toMatch(new RegExp(`refs/pull/1/merge \\([0-9a-f]{12}\\) merges ${head.slice(0, 12)}, not the PR head ${moved.slice(0, 12)}.*\\(QUEUE_SCREEN_STALE_REF\\)`));
|
||||
expect(line(stale.out, 'NEXT:')).toBe('NEXT: resolve the conflict with main (or wait for GitHub to recompute refs/pull/1/merge), push, then gstack-ship-queue ready --pr 1 --screen; a screen dispatched on a stale merge ref plans no cases');
|
||||
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([stale.status, stale.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(stale.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${moved.slice(0, 12)} onto main@[0-9a-f]{12} \\(refs/pull/1/merge [0-9a-f]{12} merges ${head.slice(0, 12)}, not ${moved.slice(0, 12)}\\); would push screen/1-${moved.slice(0, 12)} \\(dry run\\)$`));
|
||||
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.err).toMatch(/has no refs\/pull\/1\/merge.*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([missing.status, missing.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(missing.out, 'SCREEN_MERGE:')).toContain('(origin has no refs/pull/1/merge); would push');
|
||||
const tree = repo.git('rev-parse', `${moved}^{tree}`);
|
||||
const main = repo.git('rev-parse', 'origin/main');
|
||||
const badMerge = spawnSync('git', ['-C', repo.origin, 'hash-object', '-t', 'commit', '-w', '--stdin', '--literally'], { input: `tree ${tree}\nparent ${main}\nparent ${moved}\nauthor A <a@example.com> 1700000000 +0000\ncommitter B 1700000000 +0000\n\nmerge without a committer email\n`, encoding: 'utf8', timeout: 30_000 }).stdout.trim();
|
||||
spawnSync('git', ['-C', repo.origin, 'update-ref', 'refs/pull/1/merge', badMerge], { timeout: 30_000 });
|
||||
repo.git('config', 'fetch.fsckObjects', 'true');
|
||||
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(unfetchable.status).toBe(EXIT.refused);
|
||||
expect(unfetchable.err).toMatch(/could not fetch refs\/pull\/1\/merge from origin: .*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(line(unfetchable.out, 'NEXT:')).toBe('NEXT: check the remote, then gstack-ship-queue ready --pr 1 --screen');
|
||||
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([unfetchable.status, unfetchable.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(unfetchable.out, 'SCREEN_MERGE:')).toMatch(/\(refs\/pull\/1\/merge [0-9a-f]{12} could not be fetched\); would push/);
|
||||
repo.git('config', '--unset', 'fetch.fsckObjects');
|
||||
const originUrl = repo.git('remote', 'get-url', 'origin');
|
||||
repo.git('remote', 'set-url', 'origin', path.join(repo.root, 'no-such-origin.git'));
|
||||
const unlisted = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
repo.git('remote', 'set-url', 'origin', originUrl);
|
||||
expect(unlisted.status).toBe(EXIT.refused);
|
||||
expect(unlisted.err).toMatch(/could not list origin's refs: .*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(unlisted.err).toMatch(/git fetch origin main failed: .*\(POLICY_SOURCE_UNAVAILABLE\)/);
|
||||
expect(unlisted.err.split('\n').filter(Boolean).length).toBe(1);
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
repo.git('push', '-q', '-f', 'origin', `${mergeRefCommit(fx, moved)}:refs/pull/1/merge`);
|
||||
const fresh = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([fresh.status, fresh.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(fresh.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: GitHub's refs/pull/1/merge [0-9a-f]{12} merges the head ${moved.slice(0, 12)} onto main@[0-9a-f]{12}$`));
|
||||
expect(line(fresh.out, 'SCREEN_DISPATCH:')).toContain('(dry run: not dispatched)');
|
||||
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a finished screen that planned 0 cases never counts as a screen: ready dispatches a fresh one instead of adopting it and train build seats the slot unscreened with a SCREEN: line;
|
||||
// fails_when=the census is not read (a hollow run is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
|
||||
// fails_when=the census is not read (a not-run screen is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
|
||||
test('a screen run that planned 0 cases is not followed by ready and is counted as not-run by train build', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; });
|
||||
@@ -260,7 +260,7 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 0 } }; });
|
||||
const emptyPlan = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(line(emptyPlan.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 102 .*not following run 101 planned no cases/);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 } }; });
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, binding: { verdict: 'GREEN' } }; });
|
||||
const live = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(live.status).toBe(EXIT.ok);
|
||||
expect(line(live.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 103 .*not following run 102 planned no cases \(an empty plan manifest; concluded success\)/);
|
||||
@@ -269,10 +269,11 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(line(followed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 103 (same merge SHA, base and policy; no duplicate)');
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(4);
|
||||
const liveBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });
|
||||
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 GREEN']);
|
||||
fx.write(d => { for (const run of d.runs) if ((run as any).databaseId === 103) Object.assign(run, { status: 'in_progress', conclusion: null }); });
|
||||
const runningBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
|
||||
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 still running; counted as screened']);
|
||||
fx.write(d => { for (const run of d.runs) if ((run as any).databaseId === 103) Object.assign(run, { status: 'completed', conclusion: 'success' }); });
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success' }; d.artifacts['103']['paid-plan'] = { reuse: 'confirmed' }; });
|
||||
const unreadable = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([unreadable.status, line(unreadable.out, 'ORDER:'), line(unreadable.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', "SCREEN: #1 run 103's paid-plan manifest.json is unreadable; counted as screened, unverified"]);
|
||||
|
||||
@@ -45,7 +45,9 @@ describe('stamp templates (pure)', () => {
|
||||
expect(rewriteStamps('"version": "1.2.3.0",', '1.2.3.0', '1.2.4.0', ['"version": "{version}"']).count).toBe(1);
|
||||
expect(rewriteStamps('<!-- x-plugin-tree-stamp: 1.2.3.0 -->', '1.2.3.0', '1.2.4.0', ['<!-- x-plugin-tree-stamp: {version} -->']).text).toBe('<!-- x-plugin-tree-stamp: 1.2.4.0 -->');
|
||||
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
// A quoted since: row is a built-in form too (gbrain #7640): the template and the built-in count it once.
|
||||
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "since: '1.2.4.0'", count: 1 });
|
||||
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(1);
|
||||
});
|
||||
|
||||
test('policy: stamp_patterns are bounded literal templates with exactly one {version}; stamp_files are contained globs', () => {
|
||||
|
||||
Reference in new issue
Block a user