mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
v1.91.262.0 fix(release-cut,ship-queue): the weekly release accepts a train-gated commit; cross-repo refs, run ids, pr-body section merge, pregate by tree (#3483)
* fix(release-cut,ship-queue): the weekly release accepts a train-gated commit, and five lane frictions seen in operation scripts/release-cut.ts gates a main commit either by a successful backstop check run or by a green/merging/merged train record in refs/notes/gstack-trains whose merged[] names its sha or tree (CANDIDATE: ... gated by train <nonce>); a red check, a running run, the required check and the freeze are judged as before. --required-check, --backstop-check and --notes-ref make the names configurable. lib/issue-links.ts reads (name #N[, #M]), (name PR #N) and (name issue #N) as cross-repo references. lib/gstack-post.ts allowlists an 11-digit run id after a run word or chained after one, and pr-body merges by ## section into the PR's current body (--replace overwrites). gstack-pregate mirrors each record under <state root>/pregate/by-tree/<tree>.json and ready reads the mirror for its tree when the checkout's record is missing or for another tree. docs/troubleshooting.md gains #redact-run-id-as-phone, #gate-error-no-base and #gstack-post-pr-body-merge. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 1) Fence-length-aware section splitting in gstack-post pr-body (an unclosed receipt fence or a four-backtick block no longer exposes a heading to the merge), the run-id allowlist drops generic words (id, job, attempt, workflow), ready accepts a by-tree pregate mirror only from a complete run against origin/<base> under a reviewed policy and prints no home path, release-cut skips non-object notes and never substitutes train evidence for the required check when both names are equal, issue-links treats closing/cf./e.g./v1.2 as words rather than repositories. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 2) An unclosed receipt fence makes the body from that line on one section the merge never matches, so posted leading prose cannot replace it and a closed receipt before it stays a receipt; the by-tree pregate mirror must hold the tests stage (what --stage tests --remote writes) rather than every check; empty sha/tree strings in a train note are not evidence; (v1.2.3-rc1 #301) is a version word, not a repository. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CANDIDATE line names its gate; issue-links reference documents the (name #N) form Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): a PREGATE_ENVIRONMENT local record is final; no mirror stands in for it Merging origin/mainb004c06(#3480) into this branch created a semantic conflict between two individually correct parents: #3480 made verifyPregate return PREGATE_ENVIRONMENT (instead of PREGATE_INCOMPLETE) when every incomplete check was caused by the environment, and this branch's by-tree mirror reader in ready() treated only PREGATE_LANE_FAILED and PREGATE_INCOMPLETE as final. Merged, a local record whose secrets scan never finished fell through to a same-tree tests-only mirror and ready passed without a secrets scan, against the rule this branch documents ("a record whose own checks failed or stayed incomplete is never overridden"). ready() now keeps a PREGATE_ENVIRONMENT record final as well. Regression in test/gstack-ship-queue.test.ts ("one READY block ..."): an environment-incomplete secrets record beside a passing same-tree mirror is refused with (PREGATE_ENVIRONMENT) and no "from the state root" suffix. Red on the bare merge28b64d7(expected exit 3, received 0), green with this fix. Found by the pass-4 delta review of the merge. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.262.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
51a2b19b75
commit
a8decce8ac
17 files changed
+1001
-76
No files matched your search
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.261.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.262.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,
|
||||
|
||||
+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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+42
-2
@@ -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 };
|
||||
@@ -178,6 +186,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 +229,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.261",
|
||||
"version": "1.91.262",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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] });
|
||||
|
||||
Reference in new issue
Block a user