diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9acfbf0..f4b41fb8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.91.339.0] - 2026-10-30 + +**Merge trains can stamp every slot themselves under `changelog_fragments`: `train build` builds a stamped prefix for each slot and `train land` publishes it, so no lane stamps a PR by hand.** gstack's own policy still leaves the key off (docs/ship-policy.md#changelog-fragments). + +### Added +- **Stamped merge trains** (`lib/ship-queue-train-stamp.ts`, `lib/ship-queue-train-land.ts`): under `changelog_fragments`, `train build` also builds S_i = stamp(merge(S_(i-1), pin_i)) for every slot, checks each is the merge modulo stamps, keeps every earlier release entry byte for byte and equals the plain prefix outside release paths (`QUEUE_TRAIN_STAMP_MISMATCH`), refuses a symlinked release file before stamping, and gates S_k. `train land --train --pr ` publishes slot i's S_i exactly. It makes a deterministic stamp commit on [pin, main], journals it before it is pushed under the pin's lease with the `evals-no-debounce` label, waits for checks, merges with `--match-head-commit` and confirms main's tree; a rerun adopts its own stamp head, a PR retargeted off the base refuses, and any other main freezes the queue as before. It first verifies the whole record against git (T0 and S_1..S_k must be the gated head's first-parent chain, each S_i's second parent slot i's pin) and uses only that chain, and it merges only when every name in the new `changelog_fragments.required_checks` is present and green on the stamp commit. `verify --train` refuses a stamped train and names `train land`; `train bisect` of a stamped train tests T0 and S_1..S_k. + +### Changed +- **The main backstop covers a stamped train's merges by exact tree** (`scripts/train-backstop.ts`, `scripts/train-gate.ts`): a record's S_i prefixes cover main only when its tree is exactly S_i; plain T_i prefixes still cover modulo stamps. +- **A train merge keeps a pin's manifest edits** (`lib/ship-queue-train.ts`): a `package.json` or lockfile conflict against the previous prefix merges three-way with the prefix's version (the same `mergeManifest` land uses) instead of taking the prefix's whole file; anything else that conflicts is a train conflict. +- **Version Gate reads a stacked child's stamp commit by its own fragments**: a parent's fragment the base has already released is skipped rather than counted again. + +### Tests +- `test/ship-queue-train-stamp.test.ts`: S2 keeps S1's release and runs the versions in order with each slot's bump; a stacked child folds only its own fragment; a four-part package.json base keeps four parts in every S_i, read from S_(i-1); a pin's manifest edit beside the version line survives the previous slot's bump; a symlinked CHANGELOG refuses before anything is written outside; a tampered earlier version or a product change is refused. +- `test/ship-queue-train-land.test.ts`: build records S1 and S2 and gates S2; land publishes each slot exactly with the label; a foreign push is never overwritten; a merge outside the train freezes the queue; a failed stamp commit pushes nothing; a rerun after the push or after the journal resumes; a retargeted PR refuses; a red stamped train bisects its stamped chain; a plain train refuses `train land`. +- `test/train-backstop.test.ts`: main equal to S1 is covered; the same tree with another VERSION is gated; a chain mixing stamped and plain prefixes is refused. +- `test/version-gate-fragments.test.ts`: a stacked child's stamp counts only its own fragment once its parent has landed. + ## [1.91.338.0] - 2026-10-30 **A sealed-guard waiver now counts only when it names the full commit sha.** A `.gstack/sealed-allow.tsv` line used to accept a sha as short as 7 hex characters and waive any commit whose sha started with it. A PR author could generate a commit with the same first 7 characters in minutes and publish different content at the same path under an old, reviewed waiver. A waiver now matches only the finding's full commit sha (40 hex characters, 64 in a SHA-256 repository). A shortened, uppercase or mistyped sha is ignored, so the finding still refuses, and stderr says which line was ignored and which commit the finding is in, to review before anyone waives it. diff --git a/VERSION b/VERSION index f7d51a4b5..6536d3b7f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.338.0 +1.91.339.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4f01c7aae..62d8b8fe8 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.338.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.339.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, diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index eb6e79224..bc777e96e 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -281,6 +281,7 @@ gstack_result_anchor() { QUEUE_TRAIN_BISECT_CAPPED) printf '%s' 'queue-train-bisect-capped' ;; QUEUE_TRAIN_PREFIX_INVALID) printf '%s' 'queue-train-prefix-invalid' ;; QUEUE_TRAIN_REBUILD_REFUSED) printf '%s' 'queue-train-rebuild-refused' ;; + QUEUE_TRAIN_STAMP_MISMATCH) printf '%s' 'queue-train-stamp-mismatch' ;; QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;; QUEUE_TRAIN_STALE) printf '%s' 'queue-train-stale' ;; QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; diff --git a/captain/templates/READY.md b/captain/templates/READY.md index 73ce6de02..bbeb09fa3 100644 --- a/captain/templates/READY.md +++ b/captain/templates/READY.md @@ -20,6 +20,10 @@ SHIP_RECEIPT: pr= head= version=- base= tree= session_kind READY_RECEIPT: () ``` +Under `changelog_fragments` (docs/ship-policy.md#changelog-fragments) the PR's entry is a +fragment, `changelog.d/.md`, never an `[Unreleased]` section or a version, and the +controller merges with `train land` or `land`, so no slot call follows this block. + The lane's own lines, after the block: ```text diff --git a/captain/templates/slot-call.md b/captain/templates/slot-call.md index 16d6cd855..f1908884a 100644 --- a/captain/templates/slot-call.md +++ b/captain/templates/slot-call.md @@ -1,7 +1,9 @@ # Slot call Sent by the controller to one lane at a time, in train order, after the train verdict is -GREEN and the previous slot has merged and verified. +GREEN and the previous slot has merged and verified. Plain trains only: a train built under +`changelog_fragments` has no slot call; the controller runs `train land --train --pr ` +(docs/ship-policy.md#train-land). ```text # slot , base , order diff --git a/docs/captain.md b/docs/captain.md index d450b184c..ac20593c3 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -270,6 +270,13 @@ loop, per train: `gh pr merge --match-head-commit`; `verify --train` again. 4. `train prune` when every slot has merged. +Under `changelog_fragments` ([ship-policy.md](ship-policy.md#train-land)) lanes READY with a +fragment (`changelog.d/.md`) instead of an `[Unreleased]` section, there is no slot +call and no lane stamp, and step 3 is one command per slot: `train land --train --pr +` commits the stamped prefix S_i on [pin, main], pushes it, waits for checks, merges and +confirms main is S_i exactly. Outside a train, `gstack-ship-queue land --pr ` is the same +step for one PR ([ship-policy.md](ship-policy.md#land)). + A red train: `train bisect` on the red cases over every prefix. An attributed PR leaves the train and goes back to its lane; red on the base branch itself is fixed forward through an urgent one-PR train (or `train build --repair` while the queue is frozen, which diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md index c6893c918..0f37432c7 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -129,8 +129,9 @@ A lane (one crew thread, one branch) does this, in order. Every command is harness-generic; only the thread and the PR tool are Capy. 1. **Branch from the default branch, build, test with focused checks.** Write - the CHANGELOG entry under `## [Unreleased]`; leave VERSION at main's value. - Never pre-pick a version. + the CHANGELOG entry under `## [Unreleased]` (under `changelog_fragments`, a + fragment in the policy's fragment directory instead); leave VERSION at + main's value. Never pre-pick a version. 2. **Push the final head.** Everything below binds to the pushed head; a later push invalidates the receipt, the pregate record and the review binding. 3. **Open the PR** with the host's PR tool (Capy: `tools.pr.create`, never @@ -182,6 +183,11 @@ reference: `, or `--merge` where the repository keeps merge commits), and verifies again. A stamp commit that holds anything beyond the stamp files fails `verify --train`, freezes the queue and costs a backstop gate. + Under `changelog_fragments` there is no slot call and no lane stamp: the + controller runs `gstack-ship-queue train land --train --pr 41` per + slot, which pushes the stamped prefix the gate covered, waits for checks, + merges and confirms main + ([ship-policy.md#train-land](ship-policy.md#train-land)). 4. `train prune` when every slot has merged. **Who merges.** One controller, never the lane. Both crews that ran this @@ -321,7 +327,7 @@ the Captain's Drive holds the longer history behind these numbers; from here on | Running a workflow | the SKILL.md files, `GSTACK_SESSION_KIND=unattended`, `GSTACK_RESULT:` lines | the `GSTACK_LAUNCH` assignments, `GSTACK_CAPY=1` and the `capy-asks` block, no enforced hooks (safety skills are advisory) | | Approvals | `gstack-approvals`, `gstack-owner-brief render --captain`, the `## Approvals` table | the Captain relay (`needs_you`), the Captain drive `approvals/` mirror | | Pull requests | `/ship`, the ship receipt, `gstack-pr-shots` | `tools.pr.create` (callbacks), `tools.pr.uploadAsset` (before/after images) | -| Merging | `gstack-ship-queue` (ready, train build, verdict, verify), `gstack-restamp`, `gh pr merge --match-head-commit` | the controller is a Captain thread; slot calls are thread messages | +| Merging | `gstack-ship-queue` (ready, train build, verdict, verify; train land and land under `changelog_fragments`), `gstack-restamp`, `gh pr merge --match-head-commit` | the controller is a Captain thread; slot calls are thread messages | | Flaky pass | the rerun rule, `flake-records-a*` and `flake-ledger-*` artifacts, `test:health` | the daily automation and the ledger in the Captain's Drive | | Release | `release-cut`, `weekly-release.yml`, the `stable` channel | nothing | | Scorecard | `gstack-factory-scorecard`, `/retro` Step 7.5 | nothing (the CSV can be kept in the Captain's Drive) | diff --git a/docs/ship-policy.md b/docs/ship-policy.md index d3cf4ba0d..13cbba4f9 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -61,7 +61,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | -| `changelog_fragments` | `{ dir }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | +| `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | Containment, checked by `validate` and again at write time: paths and globs are repo-relative with no `..` segment, under 256 characters, at most 64 per list, @@ -365,6 +365,61 @@ are mirrored to origin as git notes on `refs/notes/gstack-trains` before any remote effect, so a lost machine loses nothing: `train status` on a clean state root rebuilds them. `GSTACK_SHIP_QUEUE_NOW` pins the clock for tests. + +### Stamped trains under `changelog_fragments`: `train land` + +When the base policy also declares [`changelog_fragments`](#changelog-fragments), +`train build` builds every prefix twice. T_i is the plain chain above. S_i is +what slot i publishes: S_0 is the base and S_i = stamp(merge(S_(i-1), pin_i)), +stamped by `gstack-restamp` with S_(i-1) as its base, so slot i folds only its +own fragments (a stacked pin's parent fragments are already folded) and the +versions run in slot order, each with its slot's effective bump. Before +anything is pushed, each S_i must be merge(S_(i-1), pin_i) modulo stamps, +keep every release entry of S_(i-1) byte for byte, and equal T_i outside +release paths (version files, CHANGELOG, fragments, `release_outputs`, +`stamp_paths`, `stamp_files`); otherwise `QUEUE_TRAIN_STAMP_MISMATCH`. Pass +`--allow-repo-commands` when the policy has a `restamp_command` or +`release_tool`. The record keeps both chains (`prefixes` and `stamped`), the +train head and branch are S_k, and the one full gate runs on S_k, the exact +tree the last merge publishes. + +Slots merge with `train land --train --pr `, never a slot call: + +1. The record must be the gated head's own chain. Walking first parents from + S_k gives T0 and S_1..S_k with the recorded commits and trees, and each + S_i's second parent is slot i's pin. A record rebuilt from notes that + differs anywhere refuses (`QUEUE_PROOF_MISSING`), and every later step uses + only that verified chain. main's tree must equal S_(i-1)'s exactly (slot 1: + the train base), and the PR's head its pin, with its /ship receipt. main + equal to an earlier prefix refuses and names the slot to land first; main + equal to a later prefix confirms the slot (a rerun). A base that cannot be + fetched refuses (`QUEUE_PROOF_MISSING`), a fork head refuses + (`QUEUE_TRAIN_UNTRUSTED`), and a PR whose base is no longer the train's + base refuses (`QUEUE_NOT_READY`), here and again before the merge. +2. It commits S_i's tree with parents [pin, main], adds the + `evals-no-debounce` label (a tool-made head gets no later push, so the + 10-minute quiet window protects nothing), and pushes under + `--force-with-lease=:`; a foreign push is never overwritten + (`QUEUE_TRAIN_MOVED`). The stamp commit is deterministic and journaled + (`land-.json` beside the train record) before the push, so a rerun + adopts its own stamp head instead of reading it as a move. A push that + fails while the branch still reads the pin is `QUEUE_NOT_READY`; rerun. +3. It waits for the PR's checks on that commit (`--no-wait` prints the + command). Required checks run live at the slot until GSTA-156's exact-tree + reuse is on main. Every name in `changelog_fragments.required_checks` must + be present and green on the stamp commit (a check run of that name, all of + its ` (…)` matrix runs, or a status context); a missing one holds the + merge (`QUEUE_NOT_READY`) and the fix names it. Without the list, any green + set counts, so set it to the checks branch protection does not enforce. +4. It refuses if main moved during the checks, merges with + `--match-head-commit`, and confirms main's tree is S_i exactly. Any other + main is the freeze path above (`QUEUE_TRAIN_TREE_MISMATCH`, backstop). + +`verify --train` refuses a stamped train and names `train land`. The backstop +covers main only when its exact tree is some S_i of a green record (plain +T_i records still cover modulo stamps). A dropped slot changes every later +version, so the train is rebuilt, as today. + **0. Lane: /ship before READY.** Every READY follows a /ship run on the branch at the head being READY'd, including a re-READY after a push (Garry, @@ -508,9 +563,10 @@ records does not stand; see [the ship receipt](#ship-receipt)), `QUEUE_TRAIN_CONFLICT: train stopped at # (conflicts: ) after merging #,#; nothing dispatched, no branch pushed; fix: …` line names the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red, -missing, cancelled or on another SHA). In a merge, version files take the -prefix's side and the CHANGELOG merges by section, since the stamp at merge -rewrites both; a PR whose CHANGELOG drops or renames a released heading (or +missing, cancelled or on another SHA). In a merge, the version file takes the +prefix's side, a manifest or npm lockfile conflict merges three-way with the +prefix's version (so a pin's dependency edit survives), and the CHANGELOG +merges by section, since the stamp at merge rewrites them; a PR whose CHANGELOG drops or renames a released heading (or carries a released entry's text in a section of its own) is a CHANGELOG conflict, never merged with the damage, except the stacked child whose parent was released since their merge base, which `mergeChangelogs` carries (see *A @@ -545,7 +601,8 @@ gstack-ship-queue train rebuild --train ``` The bisect dispatches the red cases (`cases`) on every prefix T0..Tk -(`prefix_shas`), one trial each, after checking every prefix is an ancestor +(`prefix_shas`; for a [stamped train](#train-land), T0 and S_1..S_k, the +chain its gate ran), one trial each, after checking every prefix is an ancestor of `train/` (`QUEUE_TRAIN_PREFIX_INVALID`). It reserves every case×prefix trial (unknown costs at `trial_reservation_usd`) and launches only the prefixes that fit the cap (`QUEUE_TRAIN_BISECT_CAPPED`; completed prefixes @@ -1177,7 +1234,7 @@ writes only stamps. The command also gets `GSTACK_RESTAMP_BASE_SHA`, the release predecessor: the base branch commit the version is allocated after (origin/ for a per-PR -restamp and for `land`). A repo counter that must be predecessor + 1 (a +restamp and for `land`, S_(i-1) for a [stamped train](#train-land)'s S_i). A repo counter that must be predecessor + 1 (a SAVE_VERSION, a rules number) reads its old value from that commit (`git show "$GSTACK_RESTAMP_BASE_SHA:"`), never from the branch, so two PRs cut from one base never both claim the same number. Regenerated goldens go in @@ -1239,7 +1296,8 @@ valid fragment (a legacy `[Unreleased]`-only entry passes during migration; a `[bot]` author, such as Dependabot, needs none). Other manifest edits, a dependency or a script, pass. A head made by the merge step, a commit whose parents are [pin, base], must carry the base's version plus the effective -bump of the fragments its pin adds. Under a train policy `gstack-ship-queue +bump of the fragments its pin adds; a stacked pin's parent fragment whose +text the base already released is skipped, not counted again. Under a train policy `gstack-ship-queue ready` applies the same rule itself; without one, `ready` requires the head's checks green, Version Gate included. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2b397f270..dfb5e5f18 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4244,12 +4244,18 @@ HEAD is not the PR's pushed head (read through `gh pr view`), CI at that exact head is not green (failed, pending or unreadable), or the pregate result is missing, failed or recorded on another tree (`PREGATE_STALE`). Under `--dispatch-ci` it is also a dispatched workflow run that concluded red (a red -run clears no pregate lane) or a `gh workflow run` that failed. +run clears no pregate lane) or a `gh workflow run` that failed. `train land` +uses it while a slot cannot merge yet: checks on the stamp commit are not +green, a name in `changelog_fragments.required_checks` is missing or not green +there, the PR's base is no longer the train's base, the stamp push failed while +the branch still read the pin, `gh pr merge` refused, or the merge is not yet +visible on main. **Fix.** Commit and push, wait for CI on that head, rerun `gstack-pregate run --allow-repo-commands` when the tree changed, then rerun `gstack-ship-queue ready --pr `. The block it prints before refusing shows -which line is not yet true. +which line is not yet true. For `train land`, do what `NEXT:` says, then rerun +the same `train land` command; it adopts its own journaled stamp commit. ### `QUEUE_PROOF_MISSING` @@ -4265,8 +4271,10 @@ train still building, running or merging, `train watch` on an unknown record, unreadable run), `verify --pr --train ` when main's tree mismatches and whether a slot merged on GitHub cannot be read (so whether the queue must freeze is unknown; it never answers "nothing merged" from an unreadable -state), and `ready --screen` when the scratch merge branch cannot be -pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says +state), `train land` when the base or the train branch cannot be fetched or +the record's T0 and S_1..S_k are not the gated head's first-parent chain +(each S_i's second parent slot i's pin), and `ready --screen` when the +scratch merge branch cannot be pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says `committed=yes`. **Fix.** Supply the named input (fetch the gated head, pass the current base @@ -4305,7 +4313,9 @@ predecessor PR moved after the gate. **Meaning.** The ship policy sets `full_e2e: true`, and no passing full-gate result is bound to the tree being stamped or verified: no recorded gate-ahead attempt for that gated head, no `--gate-exit 0 --gate-log ` from a run -on it, or the recorded run exited non-zero. +on it, or the recorded run exited non-zero. The train commands use it too: a +PR that is not in the train, a train without a green gate, `verify --train` on +a stamped train and `train land` on a plain one (each names the other). **Fix.** Run the gate the policy's `gate_command` names in the gate-ahead worktree and bind it with `gstack-ship-queue gate-ahead --record`, or pass the @@ -4343,7 +4353,7 @@ a `train` block with `min_gstack_version` on the base branch first **Meaning.** A pinned PR's head repository is not the base repository. The train branch and its dispatches run with repository secrets, so fork work never rides a train. `ready --dispatch-ci` refuses a fork head for the same -reason and dispatches nothing. +reason and dispatches nothing, and `train land` refuses one and pushes nothing. **Fix.** Run the printed `train build --prs `; the fork PR stays on the trusted-branch path. @@ -4407,13 +4417,28 @@ the two allowed restamps, so `land` stopped instead of restamping again. it), then rerun `gstack-ship-queue land --pr `; the lock was released and the rerun restamps from the pin. + +### `QUEUE_TRAIN_STAMP_MISMATCH` + +**Meaning.** While building a stamped train (`changelog_fragments`), a prefix +S_i could not be stamped, or it is not the merge of S_(i-1) and its pin +modulo stamps, or it rewrote an earlier release entry, or it differs from the +plain prefix T_i outside release paths. Nothing was pushed or dispatched. See +[stamped trains](ship-policy.md#train-land). + +**Fix.** Read the named slot and files. A stamp hook (`restamp_command`, +`release_tool`) that writes product files is fixed in the hook; an invalid +fragment goes back to its lane. Otherwise rebuild the train without that slot +and report it. + ### `QUEUE_TRAIN_MOVED` **Meaning.** A PR's head is no longer the SHA the train pinned (at build, or past its pin beyond stamps at verify), so the train would not test what merges. At verify after earlier slots merged, the queue also freezes -(`QUEUE_TRAIN_FROZEN`). +(`QUEUE_TRAIN_FROZEN`). At `train land` it is a PR head or branch that moved +off the pin (or off land's own stamp commit); land never overwrites it. **Fix.** Ask the lane for a fresh READY on the new head, then build the train with the new pin (`train build --prs @,…`). @@ -4566,6 +4591,10 @@ tested. After a merge, a main that differs from the prefix after the PR in the CHANGELOG alone, while being its own first parent's CHANGELOG plus the PR's own entry (the rule the pre-merge verify applied to the head), is not a mismatch ([one CHANGELOG rule, before and after](ship-policy.md#run-a-merge-train)). +For a stamped train, `train land` compares exact trees: main must be S_(i-1) +before the merge, must not move during the slot's checks, and must be S_i +after it ([stamped trains](ship-policy.md#train-land)); main equal to an +earlier prefix refuses without freezing and names the slot to land first. **Fix.** Stop merging. If a PR of the train merged (verified, or reported MERGED by GitHub without a verified tree, slot 1's own squash included), the diff --git a/lib/changelog-fragments.ts b/lib/changelog-fragments.ts index 5fb0a1000..3f5404cf8 100644 --- a/lib/changelog-fragments.ts +++ b/lib/changelog-fragments.ts @@ -60,8 +60,9 @@ export function parseFragment(rel: string, text: string): Fragment | FragmentErr * stacked pin's parent): a path present there with the same blob is skipped, a path present there * with other bytes is an error. With `changelog`, a fragment whose lines appear whole and in order in a released * section of that file at `exclude` is an error too: a landed PR's fragment carried back in by stacked history. + * `onReleased: 'skip'` drops such a fragment instead: a merge-step stamp commit never folds it. */ -export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null, changelog?: string | null): FragmentSet { +export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null, changelog?: string | null, onReleased: 'error' | 'skip' = 'error'): FragmentSet { const prefix = dir.replace(/\/+$/, '') + '/'; const list = (t: string | null) => { const r = t === null ? git(cwd, ['ls-files', '-s', '-z', '--', prefix]) : git(cwd, ['ls-tree', '-r', '-z', '--full-tree', t, '--', prefix]); @@ -98,7 +99,7 @@ export function readFragments(cwd: string, treeish: string | null, dir: string, if (!blob.ok) { err('blob unreadable'); continue; } const parsed = parseFragment(e.path, blob.out); if ('code' in parsed) errors.push(parsed); - else if (isReleased(parsed.body)) err(`its text is already released in ${changelog} on the base (a landed PR's fragment carried in by stacked history); delete it from this branch`); + else if (isReleased(parsed.body)) { if (onReleased === 'error') err(`its text is already released in ${changelog} on the base (a landed PR's fragment carried in by stacked history); delete it from this branch`); } else fragments.push(parsed); } return { fragments, errors }; diff --git a/lib/restamp.ts b/lib/restamp.ts index 69e2152a9..c9b4c7707 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -57,7 +57,7 @@ export interface RestampOptions { repoRoot: string; base: string; version?: string; next?: boolean; bump?: Bump; queueMode?: QueueMode; after?: { pr: string; ref?: string }; pr?: string; expectBase?: string; expectOrder?: string[]; was?: string; policyFrom: 'base' | 'head'; allowRepoCommands: boolean; dryRun: boolean; now?: Date; env?: NodeJS.ProcessEnv; - gh?: (args: string[]) => { ok: boolean; out: string }; failAfter?: string; gateWorktree?: string; + gh?: (args: string[]) => { ok: boolean; out: string }; failAfter?: string; gateWorktree?: string; baseRef?: string; } export interface RestampResult { status: 'stamped' | 'noop' | 'dry-run' | 'refused' | 'failed' | 'gate-ahead'; @@ -164,7 +164,7 @@ interface Plan { } function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: string): Plan { - const baseRef = `origin/${o.base}`; + const baseRef = o.baseRef ?? `origin/${o.base}`; const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); if (!baseSha.ok) throw new Refusal('POLICY_SOURCE_UNAVAILABLE', `${baseRef} is not a resolvable commit; nothing written`, { fix: `git fetch origin ${o.base} --depth=1` }); if (o.expectBase && !baseSha.out.startsWith(o.expectBase)) { @@ -655,7 +655,7 @@ function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: function fragmentsOf(o: RestampOptions, dir: string, changelog: string | null): FragmentSet { const fp = wtreeFingerprint({ cwd: o.repoRoot }); if ('error' in fp) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${fp.error}); nothing written`); - const set = readFragments(o.repoRoot, fp.tree, dir, `origin/${o.base}`, changelog); + const set = readFragments(o.repoRoot, fp.tree, dir, o.baseRef ?? `origin/${o.base}`, changelog); if (set.errors.length) throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${set.errors[0]!.path}: ${set.errors[0]!.message}; nothing written`); return set; } diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index e9551f80a..96a2f66ea 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -122,6 +122,11 @@ export const RELEASE_RESULT_CODES = { summary: 'the rebuild is not allowed: the bisect was not all green, the train was already rebuilt (or re-dispatched), or there is no bisect or INFRA record', fix: 'route the red cases to a repair lane; the train is rebuilt only once the repair merges (a changed train)', }, + QUEUE_TRAIN_STAMP_MISMATCH: { + anchor: 'queue-train-stamp-mismatch', + summary: 'a stamped train prefix S_i is not the merge of S_(i-1) and its pin modulo stamps, or differs from the plain prefix T_i outside release paths, or could not be stamped; nothing was pushed', + fix: 'read the named slot and files: a stamp hook writing product files is fixed in the hook; otherwise rebuild the train without that slot and report it', + }, QUEUE_TRAIN_TREE_MISMATCH: { anchor: 'queue-train-tree-mismatch', summary: 'main’s tree is neither the train’s prefix before this PR nor after it (modulo stamps), so the merge does not publish what the gate tested', diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 62ece4e94..424321cd7 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -42,7 +42,7 @@ export interface ShipPolicy { /** The CHANGELOG re-headed on a restamp; null for repos without one. */ changelog: string | null; /** Unversioned CHANGELOG fragments (`/.md`) a PR adds instead of an `[Unreleased]` section; null keeps `[Unreleased]`. */ - changelog_fragments: { dir: string } | null; + changelog_fragments: { dir: string; required_checks?: string[] } | null; /** Path of a paired repo to restamp identically (tier 2). */ mirror: string | null; /** `merge-only` refuses a squash or rebase plan in /ship. */ @@ -134,7 +134,7 @@ export const POLICY_EXPLANATIONS: Record = { release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.', stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.', changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.', - changelog_fragments: '{ dir } or null: a PR adds one unversioned `/.md` fragment (optional `bump: minor|major` front matter) instead of an `[Unreleased]` section and leaves VERSION, the manifest version and released CHANGELOG sections at its merge base; the restamp folds every fragment under the allocated version and deletes the files (docs/ship-policy.md#changelog-fragments). null keeps `[Unreleased]`.', + changelog_fragments: '{ dir, required_checks? } or null: a PR adds one unversioned `/.md` fragment (optional `bump: minor|major` front matter) instead of an `[Unreleased]` section and leaves VERSION, the manifest version and released CHANGELOG sections at its merge base; the restamp folds every fragment under the allocated version and deletes the files (docs/ship-policy.md#changelog-fragments). `required_checks` lists the check runs or status contexts `train land` requires present and green on a stamp commit. null keeps `[Unreleased]`.', mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.', history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.', preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.', @@ -450,8 +450,10 @@ function docsErrors(raw: unknown, errors: PolicyError[]): void { function fragmentsErrors(raw: unknown, errors: PolicyError[]): void { const d = raw as Record | null; - if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir } or null' }); return; } - for (const k of Object.keys(d)) if (k !== 'dir') errors.push({ code: 'POLICY_INVALID', path: `changelog_fragments.${k}`, message: 'unknown key' }); + if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir, required_checks? } or null' }); return; } + const rc = d.required_checks; + if (rc !== undefined && (!Array.isArray(rc) || rc.length > 50 || new Set(rc).size !== rc.length || rc.some(n => typeof n !== 'string' || !/^[^\n]{1,100}$/.test(n)))) errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments.required_checks', message: `${JSON.stringify(rc)} must be up to 50 distinct check names of 1-100 characters` }); + for (const k of Object.keys(d)) if (k !== 'dir' && k !== 'required_checks') errors.push({ code: 'POLICY_INVALID', path: `changelog_fragments.${k}`, message: 'unknown key' }); const why = typeof d.dir === 'string' ? containedRelative(d.dir) ?? (/[*?[\]{}]/.test(d.dir) || d.dir.replace(/\/+$/, '') === '.' ? 'must be one directory, not a glob or the repository root' : null) : 'must be a string'; if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: 'changelog_fragments.dir', message: `${JSON.stringify(d.dir)} ${why}` }); } diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 63e04aad8..ee71af2f6 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -35,6 +35,13 @@ function linkedSegment(wt: string, rel: string): string | null { return at < 0 ? null : segs.slice(0, at + 1).join('/'); } +/** A release file the merge step writes that is a symlink, or sits under one, in the checkout `wt`; null when none. */ +export function linkedReleaseFile(wt: string, policy: ShipPolicy, versionRel: string): string | null { + const exact = [versionRel, ...MANIFESTS, ...(policy.changelog ? [policy.changelog] : [])]; + return git(wt, ['ls-files', '-s', '-z']).out.split('\0').filter(rec => rec.startsWith('120000 ')).map(rec => rec.slice(rec.indexOf('\t') + 1)) + .find(rel => exact.includes(rel) || matchesAny([...policy.release_outputs, ...policy.stamp_paths], rel)) ?? exact.map(rel => linkedSegment(wt, rel)).find(Boolean) ?? null; +} + export interface LandJournal { schema_version: 1; pr: string; branch: string; reviewed_pin: string; last_pushed_head: string | null; stamp_commit: string | null; stamp_tree: string | null; @@ -91,27 +98,37 @@ export function resolveReleaseConflicts(wt: string, conflicts: string[], policy: if (!git(wt, ['checkout', '--theirs', '--', rel]).ok) { left.push(rel); continue; } } else if (MANIFESTS.includes(base)) { const sides = [1, 2, 3].map(n => stage(n, rel)); - if (sides.some(s => !s.ok)) { left.push(rel); continue; } - const theirs = JSON.parse(sides[2]!.out); - const pin = (text: string) => { - const o = JSON.parse(text); - if ('version' in o) o.version = theirs.version; - if (o.packages?.[''] && theirs.packages?.['']) o.packages[''].version = theirs.packages[''].version; - const indent = /^\{\n( +|\t)/.exec(text)?.[1] ?? 2; - return JSON.stringify(o, null, indent) + '\n'; - }; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-land-merge-')); - const [b, o, t] = ['base', 'ours', 'theirs'].map((n, i) => { const f = path.join(tmp, n); fs.writeFileSync(f, pin(sides[i === 0 ? 0 : i]!.out)); return f; }); - const m = spawnSync('git', ['merge-file', '-p', o!, b!, t!], { encoding: 'utf8', timeout: 30_000 }); - fs.rmSync(tmp, { recursive: true, force: true }); - if (m.status !== 0) { left.push(rel); continue; } - fs.writeFileSync(path.join(wt, rel), m.stdout); + const merged = sides.every(s => s.ok) ? mergeManifest(sides[0]!.out, sides[1]!.out, sides[2]!.out) : null; + if (merged === null) { left.push(rel); continue; } + fs.writeFileSync(path.join(wt, rel), merged); } else { left.push(rel); continue; } git(wt, ['add', '--', rel]); } return left; } +/** Three-way merge of a JSON manifest with every side's release version set to theirs'; null when anything else conflicts. */ +export function mergeManifest(base: string, ours: string, theirs: string): string | null { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-land-merge-')); + try { + const want = JSON.parse(theirs); + const pin = (text: string) => { + const o = JSON.parse(text); + if ('version' in o) o.version = want.version; + if (o.packages?.[''] && want.packages?.['']) o.packages[''].version = want.packages[''].version; + const indent = /^\{\n( +|\t)/.exec(text)?.[1] ?? 2; + return JSON.stringify(o, null, indent) + '\n'; + }; + const [b, o, t] = [base, ours, theirs].map((text, i) => { const f = path.join(tmp, String(i)); fs.writeFileSync(f, pin(text)); return f; }); + const m = spawnSync('git', ['merge-file', '-p', o!, b!, t!], { encoding: 'utf8', timeout: 30_000 }); + return m.status === 0 ? m.stdout : null; + } catch { + return null; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + /** * A legacy per-PR stamp (a claim-at-open repo): the pin moved VERSION past its merge base and headed its * own entry with that version. Before the merge, put the pin's release fields back to the merge base's @@ -192,9 +209,7 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas } } if (!git(wt, ['commit', '-q', '--no-verify', '--allow-empty', '-m', `land #${o.pr}: merge origin/${ctx.base}`]).ok) return { code: 'QUEUE_LAND_CONFLICT', message: 'cannot commit the merge in the scratch worktree' }; - const exact = [versionRel, ...MANIFESTS, ...(policy.changelog ? [policy.changelog] : [])]; - const linked = git(wt, ['ls-files', '-s', '-z']).out.split('\0').filter(rec => rec.startsWith('120000 ')).map(rec => rec.slice(rec.indexOf('\t') + 1)) - .find(rel => exact.includes(rel) || matchesAny([...policy.release_outputs, ...policy.stamp_paths], rel)) ?? exact.map(rel => linkedSegment(wt, rel)).find(Boolean); + const linked = linkedReleaseFile(wt, policy, versionRel); if (linked) return { code: 'QUEUE_LAND_CONFLICT', message: `${linked} is a symlink in the merged tree; the merge step writes release files only inside the repository` }; const r = restamp({ repoRoot: wt, base: ctx.base, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: o.now, env: process.env }); if (r.status !== 'stamped' && r.status !== 'noop') return { code: r.code ?? 'QUEUE_LAND_CONFLICT', message: `restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; @@ -216,7 +231,7 @@ function prInfo(ctx: QueueContext, pr: string): PrInfo | QueueFailure { } /** Poll the stamp commit's checks (the same read as the pin's) until they are green, one is red, or the time runs out. */ -function waitChecks(ctx: QueueContext, sha: string, minutes: number): void { +export function waitChecks(ctx: QueueContext, sha: string, minutes: number): void { const deadline = Date.now() + minutes * 60_000; for (let ci = ciAtHead(ctx, sha); !ci.green && !ci.line.startsWith('red') && Date.now() < deadline; ci = ciAtHead(ctx, sha)) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30_000); diff --git a/lib/ship-queue-train-cli.ts b/lib/ship-queue-train-cli.ts index 01fb58477..4e4111b35 100644 --- a/lib/ship-queue-train-cli.ts +++ b/lib/ship-queue-train-cli.ts @@ -19,6 +19,8 @@ import { trainForget } from './ship-queue-train-forget'; import { redactCause } from './ship-queue-train-record'; export const TRAIN_HELP = ` train build --prs @[,...] [--order ,...] [--urgent | --repair] [--dry-run [--stack-tests [--stack-tests-command ""]]] [--free-timeout ] + [--allow-repo-commands]: changelog_fragments trains stamp S_1..S_k with the + base policy's restamp_command or release_tool --order: explicit slot order, every PR in --prs once (default: screened first, then READY order) train build --train resume a train (push, free tests, dispatch) from its record @@ -27,6 +29,9 @@ export const TRAIN_HELP = ` train build --prs @[,...] [--order ,...] train status [--train ] never spends: rebuild records from refs/notes/gstack-trains, reconcile unconfirmed dispatches by nonce train verdict --train read the gate run and its record: GREEN, RED, INCOMPLETE or PENDING + train land --train --pr [--no-wait] [--timeout ] + (changelog_fragments trains) publish slot n's stamped prefix S_i: commit its tree on + [pin, main], push under the pin's lease, wait for checks, squash, confirm main is S_i train bisect --train --spend-cap [--dry-run] red cases on every prefix T0..Tk, resumable and capped train rebuild --train [--infra] the one rebuild an all-green bisect earns (or the one INFRA re-dispatch) @@ -105,6 +110,7 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise buildTrain(ctx, { pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, stackTests: stackWords?.length ? stackWords : rest.includes('--stack-tests') ? true : undefined, + allowRepoCommands: rest.includes('--allow-repo-commands'), })); } if (sub === 'status') return guarded('train status', () => trainStatus(ctx, { train: nonce })); @@ -123,6 +129,13 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise trainWatch(ctx, { train: nonce, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, freeTimeoutSec })); } if (sub === 'verdict') return guarded('train verdict', () => trainVerdict(ctx, { train: nonce })); + if (sub === 'land') { + const pr = flag(rest, '--pr'); + if (!pr || !/^\d+$/.test(pr)) return usage('train land needs --pr '); + const timeout = positive(flag(rest, '--timeout'), 60); + if (Number.isNaN(timeout)) return usage('--timeout needs minutes'); + return guarded('train land', async () => (await import('./ship-queue-train-land')).trainLand(ctx, { train: nonce, pr, noWait: rest.includes('--no-wait'), timeoutMin: timeout })); + } if (sub === 'rebuild') return guarded('train rebuild', () => trainRebuild(ctx, { train: nonce, infra: rest.includes('--infra') })); if (sub === 'bisect') { const cap = positive(flag(rest, '--spend-cap'), NaN); diff --git a/lib/ship-queue-train-github.ts b/lib/ship-queue-train-github.ts index 592a125b5..ef6d8527c 100644 --- a/lib/ship-queue-train-github.ts +++ b/lib/ship-queue-train-github.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import type { Exec, RunSummary } from '../scripts/eval-bg'; import type { QueueContext } from './ship-queue'; -export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string; body: string } +export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string; body: string; baseRefName?: string } export interface RunInfo { databaseId: number; status: string; conclusion: string | null; url: string; headSha: string; event: string; displayTitle: string } export interface CheckInfo { status: string; conclusion: string | null } @@ -34,10 +34,10 @@ export function repoSlug(ctx: QueueContext): string | null { } export function prInfo(ctx: QueueContext, pr: string): PrInfo | { error: string } { - const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName,body']); + const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName,body,baseRefName']); const p = json>(r); if (!p?.headRefOid) return { error: `cannot read PR #${pr} through gh (${(r.err ?? r.out).trim().slice(0, 120) || 'no headRefOid'})` }; - return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '', body: p.body ?? '' }; + return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '', body: p.body ?? '', ...(p.baseRefName ? { baseRefName: p.baseRefName } : {}) }; } const RUN_FIELDS = 'databaseId,status,conclusion,url,headSha,event,displayTitle'; diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts new file mode 100644 index 000000000..df3b28bec --- /dev/null +++ b/lib/ship-queue-train-land.ts @@ -0,0 +1,138 @@ +/** + * `train land --train --pr ` (docs/ship-policy.md#train-land, GSTA-154 PR B): the merge step for + * one slot of a stamped train (changelog_fragments). The train built S_1..S_k and gated S_k; slot i + * publishes S_i exactly. With main's tree equal to S_(i-1)'s (slot 1: the train base), land commits S_i's + * tree with parents [pin_i, main], labels the PR evals-no-debounce (a tool-made head gets no later push, + * so the quiet window protects nothing), pushes under --force-with-lease=:, waits for the + * PR's checks on that commit (CI runs live at the slot until GSTA-156's reuse is on main), merges with + * --match-head-commit and confirms main's tree is S_i. Any other main is the freeze path + * (QUEUE_TRAIN_TREE_MISMATCH), as for verify. Journaled beside the train record; a rerun resumes. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ciAtHead, type QueueContext } from './ship-queue'; +import { git, writeJsonAtomic } from './ship-queue-gate'; +import { waitChecks } from './ship-queue-land'; +import { bindShippedReceipt } from './ship-receipt-depth'; +import { checkRun, prInfo } from './ship-queue-train-github'; +import { activeFreeze, attemptsOf, trainFile } from './ship-queue-train-record'; +import { fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outPending, outRefused, trainEnv, type TrainOut } from './ship-queue-train'; +import { confirmMerged, stopped } from './ship-queue-train-verify'; + +const Q = 'gstack-ship-queue'; +const STAMP_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue', + GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' }; +const isSha = (s: string) => /^[0-9a-f]{40,64}$/.test(s); +interface SlotJournal { schema_version: 1; pr: string; pin: string; stamp_commit: string; base: string } + +export async function trainLand(ctx: QueueContext, o: { train: string; pr: string; noWait?: boolean; timeoutMin?: number }): Promise { + const fetchErr = fetchBase(ctx); + const env = trainEnv(ctx); + if ('code' in env) return outRefused(env, [], 'none', 'none'); + if (fetchErr) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: fetchErr }, [], 'none', 'none'); + const t = findTrain(env, o.train); + if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none'); + const run = trainFile(t.nonce); + const i = t.slots.findIndex(s => s.pr === o.pr); + if (i < 0) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} does not include #${o.pr}` }, [], nextFor(t), run); + if (!t.stamped) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} was built without stamped prefixes; merge it with the slot stamp and ${Q} verify` }, [], `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run); + const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1); + if (gate?.outcome !== 'GREEN' || !['green', 'merging', 'merged'].includes(t.state)) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has no green record (state ${t.state}, last gate ${gate?.outcome ?? 'none'})` }, [], nextFor(t), run); + const check = checkRun(ctx, t.head, env.train.full_gate_check); + if (check?.conclusion !== 'success') return outRefused({ code: 'QUEUE_GATE_MISSING', message: `no successful ${env.train.full_gate_check} check run on the train head ${t.head.slice(0, 12)}` }, [], `${Q} train verdict --train ${t.nonce}`, run); + if (!fetchTrainBranch(env, t)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the train's prefix commits are not here and ${t.branch} could not be fetched` }, [], `${Q} train status --train ${t.nonce}`, run); + const freeze = activeFreeze(env.repo.keys); + if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs @`, run); + + const root = ctx.repoRoot; + const k = t.slots.length; + const chain = Array.from({ length: k + 1 }, (_, n) => { + const commit = git(root, ['rev-parse', '--verify', '-q', `${t.head}~${k - n}^{commit}`]).out; + return { index: n, commit, tree: isSha(commit) ? git(root, ['rev-parse', `${commit}^{tree}`]).out : '', pin: n && isSha(commit) ? git(root, ['rev-parse', '--verify', '-q', `${commit}^2`]).out : '' }; + }); + const recorded = [t.prefixes[0], ...t.stamped]; + if (t.stamped.length !== k || chain.some((c, n) => !isSha(c.commit) || c.commit !== recorded[n]?.commit || c.tree !== recorded[n]?.tree || (n > 0 && c.pin !== t.slots[n - 1]!.head))) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's T0 and S1..S${k} (with each slot's pin as second parent) are not the first-parent chain of the gated head ${t.head.slice(0, 12)}` }, [], `${Q} train status --train ${t.nonce}`, run); + const s = { ...chain[i + 1]!, version: t.stamped[i]!.version }; + const pred = chain[i]!; + const pin = t.slots[i]!.head; + const mainNow = (): { sha: string; tree: string } | { error: string } => { + const error = fetchBase(ctx); + if (error) return { error }; + const sha = git(root, ['rev-parse', `${env.remote}/${ctx.base}`]).out; + return { sha, tree: git(root, ['rev-parse', `${sha}^{tree}`]).out }; + }; + const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`; + const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`]; + let main = mainNow(); + if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); + if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]); + const mainTree = main.tree; + const earlier = chain.slice(0, i).find(p => p.tree === mainTree); + if (earlier) return outRefused({ code: 'QUEUE_TRAIN_TREE_MISMATCH', message: `main is ${earlier.index ? `S${earlier.index}` : 'the train base'}; land #${t.slots[earlier.index]!.pr} (slot ${earlier.index + 1}) first` }, lines, `${Q} train land --train ${t.nonce} --pr ${t.slots[earlier.index]!.pr}`, run); + const later = chain.slice(i + 2).find(p => p.tree === mainTree); + if (later) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${later.index}, past S${s.index}`]); + if (main.tree !== pred.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.sha.slice(0, 12)} is neither S${pred.index} nor S${s.index} exactly`, main.sha, lines, true); + + const file = path.join(path.dirname(run), `land-${o.pr}.json`); + let j: SlotJournal | null = null; + try { j = JSON.parse(fs.readFileSync(file, 'utf8')) as SlotJournal; } catch { j = null; } + const info = prInfo(ctx, o.pr); + if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, rerun, run); + if (info.isCrossRepository) return outRefused({ code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; train land pushes only crew branches` }, lines, 'none', run); + const retargeted = (b?: string) => b && b !== ctx.base ? outRefused({ code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${b}, not ${ctx.base}; train land merges only PRs on the base the train stamped` }, lines, `retarget #${o.pr} to ${ctx.base}, then ${rerun}`, run) : null; + const wrongBase = retargeted(info.baseRefName); + if (wrongBase) return wrongBase; + if (!j || j.base !== main.sha || info.headRefOid !== j.stamp_commit) { + const commit = git(root, ['commit-tree', '--no-gpg-sign', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`], { ...process.env, ...STAMP_IDENTITY }).out; + if (!isSha(commit)) return outFailed({ code: 'QUEUE_LAND_CONFLICT', message: `cannot create the stamp commit for tree ${s.tree.slice(0, 12)}; nothing pushed` }, lines, `${Q} train status --train ${t.nonce}`, run); + if (![pin, commit, j?.stamp_commit].includes(info.headRefOid)) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true); + const shipped = bindShippedReceipt(root, `refs/remotes/${env.remote}/${ctx.base}`, info.body, o.pr, [pin]); + lines.push(shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])); + if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, lines, `#${o.pr}'s lane runs /ship at its pin and READYs again`, run); + const label = ctx.gh(['pr', 'edit', o.pr, '--add-label', 'evals-no-debounce']); + lines.push(`LABEL: evals-no-debounce ${label.ok ? 'added' : `not added (${(label.err ?? label.out).trim().slice(0, 80)}); the 10-minute quiet window applies`}`); + j = { schema_version: 1, pr: o.pr, pin, stamp_commit: commit, base: main.sha }; + writeJsonAtomic(file, j); + const pushed = info.headRefOid !== commit; + if (pushed) { + const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${info.headRefOid}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]); + if (!push.ok) { + const remote = git(root, ['ls-remote', env.remote, `refs/heads/${info.headRefName}`]); + const now = remote.ok ? remote.out.split(/\s/)[0] ?? '' : ''; + if (remote.ok && now !== info.headRefOid && now !== commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${info.headRefOid.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); + if (!remote.ok || now !== commit) return outFailed({ code: 'QUEUE_NOT_READY', message: `the stamp push failed and ${info.headRefName} still reads ${remote.ok ? info.headRefOid.slice(0, 12) : 'unknown'}: ${push.err.split('\n')[0]}` }, lines, rerun, run); + } + } + lines.push(`${pushed ? 'PUSHED' : 'RESUMED'}: ${commit} (tree S${s.index}, parents [pin, main ${main.sha.slice(0, 12)}]) ${pushed ? 'to' : 'is already the head of'} ${info.headRefName}`); + const next = `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`; + if (o.noWait && pushed) return outPending({ code: 'QUEUE_NOT_READY', message: `checks run on the stamp commit ${commit.slice(0, 12)}` }, lines, next, run); + if (!o.noWait) waitChecks(ctx, commit, o.timeoutMin ?? 60); + } + const head = prInfo(ctx, o.pr); + if ('error' in head) return outPending({ code: 'QUEUE_PROOF_MISSING', message: head.error }, lines, rerun, run); + if (head.headRefOid !== j.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is not the stamp commit ${j.stamp_commit.slice(0, 12)}`, main.sha, lines, true); + const movedBase = retargeted(head.baseRefName); + if (movedBase) return movedBase; + const ci = ciAtHead(ctx, j.stamp_commit); + lines.push(`CI: ${ci.line}`); + const required = env.policy.policy.changelog_fragments?.required_checks ?? []; + const missing = required.filter(name => { + const runs = ci.checks.filter(c => c.name === name || c.name.startsWith(`${name} (`)); + const contexts = ci.contexts.filter(c => c.name === name); + return !(runs.length || contexts.length) || runs.some(c => c.status !== 'completed' || c.conclusion !== 'success') || contexts.some(c => c.state !== 'success'); + }); + if (missing.length) lines.push(`REQUIRED_CHECKS: not green at ${j.stamp_commit.slice(0, 12)}: ${missing.join(', ')}`); + const waitFix = `wait for ${missing.join(', ')} to pass on ${j.stamp_commit.slice(0, 12)} (dispatch the workflow on ${head.headRefName} if it never ran), then ${rerun}`; + if (missing.length) return outPending({ code: 'QUEUE_NOT_READY', message: `required checks missing or not green on the stamp commit ${j.stamp_commit.slice(0, 12)}: ${missing.join(', ')}`, fix: waitFix }, lines, waitFix, run); + if (!ci.green) return outPending({ code: 'QUEUE_NOT_READY', message: `checks on the stamp commit ${j.stamp_commit.slice(0, 12)} are not green` }, lines, `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); + main = mainNow(); + if ('error' in main) return outPending({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); + if (main.sha !== j.base) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main moved to ${main.sha.slice(0, 12)} during the slot's checks (stamped on ${j.base.slice(0, 12)})`, main.sha, lines, true); + const m = ctx.gh(['pr', 'merge', o.pr, '--squash', '--match-head-commit', j.stamp_commit]); + if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, lines, rerun, run); + main = mainNow(); + if ('error' in main) return outPending({ code: 'QUEUE_PROOF_MISSING', message: `merged; ${main.error}` }, lines, rerun, run); + if (main.sha === j.base) return outPending({ code: 'QUEUE_NOT_READY', message: `merged; main still reads ${j.base.slice(0, 12)}, so the merge is not visible yet` }, lines, rerun, run); + if (main.tree !== s.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `after the merge main ${main.sha.slice(0, 12)} is not S${s.index} exactly`, main.sha, lines, true); + return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `MERGED: #${o.pr} as ${main.sha}; main's tree is S${s.index} exactly`]); +} diff --git a/lib/ship-queue-train-record.ts b/lib/ship-queue-train-record.ts index 9b65c7619..bef44522c 100644 --- a/lib/ship-queue-train-record.ts +++ b/lib/ship-queue-train-record.ts @@ -34,12 +34,16 @@ export interface ScreenChoice { } export interface TrainSlot { pr: string; head: string; ready_at: string; screen: ScreenChoice } export interface TrainPrefix { index: number; commit: string; tree: string; pr: string | null } +/** A stamped prefix S_i (changelog_fragments trains, lib/ship-queue-train-stamp.ts): what slot i publishes, exactly. */ +export interface StampedPrefix { index: number; commit: string; tree: string; pr: string; version: string; date: string } export interface FreeRun { run: number | null; url: string | null; head_sha: string | null; conclusion: string | null; verdict: 'green' | 'red' | 'missing' | 'stale' | 'cancelled' | 'pending' } export interface TrainRecord { schema: 1; kind: 'train'; nonce: string; key: string; repo_key: string; repo: string; base: { ref: string; sha: string }; policy: string; slots: TrainSlot[]; held: string[]; prefixes: TrainPrefix[]; head: string; tree: string; branch: string; urgent: boolean; repair: boolean; explicit_order?: boolean; state: TrainState; free: FreeRun | null; attempts: string[]; merged: Array<{ pr: string; sha: string; tree: string; at: string }>; superseded_by: string | null; + /** S_1..S_k when the base policy declares changelog_fragments; head and tree are then S_k's. Absent on plain trains. */ + stamped?: StampedPrefix[]; effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string; } export type AttemptPurpose = 'gate' | 'rebuild' | 'infra-redispatch' | 'bisect'; diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts new file mode 100644 index 000000000..19549fe83 --- /dev/null +++ b/lib/ship-queue-train-stamp.ts @@ -0,0 +1,103 @@ +/** + * Stamped train prefixes (docs/ship-policy.md#run-a-merge-train, GSTA-154 PR B): under + * changelog_fragments a train is built twice. T_i is the plain chain buildChain makes (each pin merged + * onto the previous prefix, unversioned); S_i = stamp(merge(S_(i-1), pin_i)) is what slot i publishes, + * with S_0 = T_0 = the base. Each S_i is stamped by gstack-restamp with S_(i-1) as its base, so slot i + * folds only its own fragments (a stacked pin's parent fragments are already folded in S_(i-1)) and the + * versions run base+bump_1, +bump_2, ... in ORDER, every earlier release entry kept. Two checks run + * before anything is pushed: S_i is merge(S_(i-1), pin_i) modulo stamps (the tree receipt, which knows + * the fragment fold), and S_i equals T_i byte for byte outside release paths. Either failing is + * QUEUE_TRAIN_STAMP_MISMATCH. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { parseChangelog } from './changelog-check'; +import { restamp, versionAtRef, versionFiles } from './restamp'; +import { matchesAny, type ShipPolicy } from './ship-policy'; +import { git, type QueueFailure } from './ship-queue-gate'; +import { linkedReleaseFile } from './ship-queue-land'; +import { mergeCommit } from './ship-queue-train'; +import type { StampedPrefix, TrainPrefix, TrainSlot } from './ship-queue-train-record'; +import { buildReceipt } from './tree-receipt'; + +export type { StampedPrefix }; +export interface StampChainOptions { + repoRoot: string; base: string; policy: ShipPolicy; policyLabel: string; baseSha: string; + slots: TrainSlot[]; plain: TrainPrefix[]; allowRepoCommands: boolean; now: Date; +} + +const IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue', + GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' }; + +/** A path a stamp may write: version files, the CHANGELOG, fragments, release outputs, stamp paths and stamp files. */ +export function isReleasePath(root: string, policy: ShipPolicy, versionRel: string, rel: string): boolean { + const dir = policy.changelog_fragments?.dir.replace(/\/+$/, ''); + return versionFiles(root, versionRel).includes(rel) || rel === policy.changelog || (!!dir && rel.startsWith(dir + '/')) + || matchesAny(policy.release_outputs, rel) || matchesAny(policy.stamp_paths, rel) || matchesAny(policy.stamp_files, rel); +} + +/** + * S_i is merge(S_(i-1), pin_i) modulo stamps, keeps every release entry of S_(i-1) (merged's first parent) + * byte for byte, and equals T_i outside release paths. Null when all hold, else why not. + */ +export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null { + const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel }); + if ('error' in r) return r.error; + if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`; + const cl = o.policy.changelog; + if (cl) { + const read = (rev: string) => git(o.repoRoot, ['show', `${rev}:${cl}`]); + const before = read(`${merged}^1`); + const after = read(stamped); + if (before.ok && !after.ok) return `the stamp removed ${cl}`; + const kept = new Map(parseChangelog(after.out).sections.filter(s => s.version).map(s => [s.version!, s.lines.join('\n').trimEnd()])); + const lost = parseChangelog(before.ok ? before.out : '').sections.filter(s => s.version && kept.get(s.version) !== s.lines.join('\n').trimEnd()); + if (lost.length) return `the stamp rewrote or dropped the earlier release entr${lost.length > 1 ? 'ies' : 'y'} ${lost.map(s => `[${s.version}]`).join(', ')} in ${cl}`; + } + const at = versionAtRef(o.repoRoot, stamped); + const versionRel = 'rel' in at ? at.rel : 'VERSION'; + const diff = git(o.repoRoot, ['diff', '--name-only', '--no-renames', plainTree, `${stamped}^{tree}`]); + if (!diff.ok) return `cannot compare with the plain prefix: ${diff.err.split('\n')[0]}`; + const beyond = diff.out.split('\n').filter(f => f && !isReleasePath(o.repoRoot, o.policy, versionRel, f)); + return beyond.length ? `differs from the plain prefix outside release paths: ${beyond.slice(0, 8).join(', ')}` : null; +} + +/** Build S_1..S_k. A conflict, a refused stamp or a failed check stops at that slot with nothing pushed. */ +export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; lines: string[] } | QueueFailure { + const stamped: StampedPrefix[] = []; + const lines: string[] = []; + const date = o.now.toISOString().slice(0, 10); + for (const [i, slot] of o.slots.entries()) { + const pred = i === 0 ? o.baseSha : stamped[i - 1]!.commit; + const m = mergeCommit(o.repoRoot, pred, slot.head, o.policy, `train S${i + 1} (unstamped): merge #${slot.pr} at ${slot.head.slice(0, 12)}`); + if ('conflicts' in m) return { code: 'QUEUE_TRAIN_CONFLICT', message: `#${slot.pr} conflicts with S${i}: ${m.conflicts.join(', ')}` }; + const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-train-stamp-')); + fs.rmSync(wt, { recursive: true, force: true }); + if (!git(o.repoRoot, ['worktree', 'add', '--detach', '-q', wt, m.commit]).ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot check out the merge for S${i + 1}` }; + let tree = ''; + let version = ''; + try { + const at = versionAtRef(o.repoRoot, m.commit); + const linked = linkedReleaseFile(wt, o.policy, 'rel' in at ? at.rel : 'VERSION'); + if (linked) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): ${linked} is a symlink in the merged tree; the stamp writes release files only inside the repository` }; + const r = restamp({ repoRoot: wt, base: o.base, baseRef: pred, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: new Date(`${date}T12:00:00Z`), env: process.env }); + if (r.status !== 'stamped') return { code: r.code ?? 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; + if (!git(wt, ['add', '-A']).ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot stage the stamp for S${i + 1}` }; + tree = git(wt, ['write-tree']).out; + if (!/^[0-9a-f]{40,64}$/.test(tree)) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot write the stamped tree for S${i + 1}` }; + version = r.newVersion!; + } finally { + git(o.repoRoot, ['worktree', 'remove', '--force', wt]); + fs.rmSync(wt, { recursive: true, force: true }); + } + const c = git(o.repoRoot, ['commit-tree', '--no-gpg-sign', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)} v${version}`], { ...process.env, ...IDENTITY }); + if (!c.ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `commit-tree failed for S${i + 1}: ${c.err.slice(0, 120)}` }; + const plain = o.plain[i + 1]; + const why = plain ? stampMismatch(o, m.commit, c.out, plain.tree) : `no plain prefix T${i + 1}`; + if (why) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): ${why}` }; + stamped.push({ index: i + 1, commit: c.out, tree, pr: slot.pr, version, date }); + lines.push(`S${i + 1}: #${slot.pr} v${version} ${c.out.slice(0, 12)} tree=${tree.slice(0, 12)} (merge of S${i} and the pin, modulo stamps; T${i + 1} outside release paths)`); + } + return { stamped, lines }; +} diff --git a/lib/ship-queue-train-verdict.ts b/lib/ship-queue-train-verdict.ts index 3aa226431..ed8fdc3fd 100644 --- a/lib/ship-queue-train-verdict.ts +++ b/lib/ship-queue-train-verdict.ts @@ -217,6 +217,9 @@ function bisectOutcome(env: TrainEnv, t: TrainRecord, b: BisectRecord): TrainOut [...lines, `BISECT: UNATTRIBUTED (${c.all_green ? 'all green' : 'not monotone'})`], nextFor(t), run); } +/** The commits a bisect tests: T0 and the plain prefixes, or for a stamped train T0 and S_1..S_k, the chain its gate ran. */ +const bisectPrefixes = (t: TrainRecord) => t.stamped ? [t.prefixes[0]!, ...t.stamped] : t.prefixes; + /** Validate bisect inputs: red cases from the gate's own plan, every prefix an ancestor of train/'s head. */ function bisectInputs(env: TrainEnv, t: TrainRecord, red: AttemptRecord): string | null { for (const id of red.red_cases) { @@ -227,7 +230,7 @@ function bisectInputs(env: TrainEnv, t: TrainRecord, red: AttemptRecord): string if (!f.ok) return `cannot fetch ${t.branch}: ${f.err.slice(0, 120)}`; const tip = git(env.ctx.repoRoot, ['rev-parse', ref]).out; if (tip !== t.head) return `${t.branch} is at ${tip.slice(0, 12)}, not the recorded train head ${t.head.slice(0, 12)}`; - for (const p of t.prefixes) { + for (const p of bisectPrefixes(t)) { if (!git(env.ctx.repoRoot, ['merge-base', '--is-ancestor', p.commit, tip]).ok) return `prefix T${p.index} ${p.commit.slice(0, 12)} is not an ancestor of ${t.branch}`; } return null; @@ -253,7 +256,7 @@ export async function trainBisect(ctx: QueueContext, o: { train: string; spendCa const bad = bisectInputs(env, t, red); if (bad) return outRefused({ code: 'QUEUE_TRAIN_PREFIX_INVALID', message: bad }, [], `${Q} train status --train ${t.nonce}`, run); const b: BisectRecord = prior ?? { schema: 1, kind: 'train-bisect', train: t.nonce, cases: red.red_cases, spend_cap: o.spendCap, spent_usd: 0, - prefixes: t.prefixes.map(p => ({ index: p.index, sha: p.commit, results: {}, attempts: [] })), outcome: null, updated_at: nowIso() }; + prefixes: bisectPrefixes(t).map(p => ({ index: p.index, sha: p.commit, results: {}, attempts: [] })), outcome: null, updated_at: nowIso() }; b.spend_cap = o.spendCap; const slug = repoSlug(ctx); if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train bisect --train ${t.nonce} --spend-cap ${o.spendCap}`, run); diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 4d75b33ce..927656c00 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -170,7 +170,7 @@ function verifiedElsewhere(env: TrainEnv, t: TrainRecord, mainSha: string): stri * plain refusal (nothing was published). A GitHub state that cannot be read * is neither: the verify refuses and asks to be rerun, never "nothing merged". */ -async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise { +export async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise { const run = trainFile(t.nonce); // One GitHub read per unverified slot decides both the freeze and its record. const states = new Map(t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const)); @@ -197,6 +197,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str const run = trainFile(t.nonce); const i = t.slots.findIndex(s => s.pr === o.pr); if (i < 0) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} does not include #${o.pr}` }, [], nextFor(t), run); + if (t.stamped) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has stamped prefixes; its slots publish S_i exactly through train land, never a hand stamp checked modulo stamps` }, [], `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1); if (gate?.outcome !== 'GREEN' || !['green', 'merging', 'merged'].includes(t.state)) { return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has no green record (state ${t.state}, last gate ${gate?.outcome ?? 'none'})` }, [], nextFor(t), run); @@ -230,7 +231,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str `merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run); } -function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut { +export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut { const run = trainFile(t.nonce); if (!t.merged.some(m => m.pr === pr)) t.merged.push({ pr, sha: main, tree: git(env.ctx.repoRoot, ['rev-parse', `${main}^{tree}`]).out, at: nowIso() }); t.state = t.merged.length === t.slots.length ? 'merged' : 'merging'; diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index d8077fc16..fedf5681e 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -22,10 +22,12 @@ import { baseOverlapLine } from './pr-wait'; import { versionAtRef, versionFiles } from './restamp'; import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy'; import { loadBasePolicy, type QueueContext } from './ship-queue'; +import { mergeManifest } from './ship-queue-land'; import { bindShippedReceipt } from './ship-receipt-depth'; import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate'; import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen'; import { stackTestLine, stackTests } from './ship-queue-stack-tests'; +import { stampChain } from './ship-queue-train-stamp'; import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github'; import { TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile, @@ -143,7 +145,8 @@ function unionChangelog(ours: string, theirs: string): string { /** * One train merge, in object storage: the PR head onto the previous prefix. - * Version files take the prefix's side; the CHANGELOG merges by section (the + * VERSION takes the prefix's side, a manifest merges three-way with the prefix's + * version (mergeManifest), and the CHANGELOG merges by section (the * gate-ahead rule with the merge base, which also carries a stacked child * past its released parent, else a union of sections, since each heading is * restamped at merge; the union takes the carried side too, so a prefix that @@ -184,7 +187,14 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy: const own = (base && carryReleasedParent(text(ours), text(theirs), text(base))?.text) || text(ours); const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged ?? unionChangelog(own, text(theirs))); if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file); - } else if (stampFiles.includes(file)) resolved.push({ path: file, blob: theirs }); + } else if (file === stampFiles[0]) resolved.push({ path: file, blob: theirs }); + else if (stampFiles.includes(file)) { + const base = entry.get(1); + const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n'; + const merged = base ? mergeManifest(text(base), text(ours), text(theirs)) : null; + const blob = merged === null ? null : git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged); + if (blob?.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file); + } else conflicts.push(file); } if (conflicts.length) return { conflicts: conflicts.sort() }; @@ -321,6 +331,8 @@ export interface BuildOptions { pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number; /** `--dry-run --stack-tests`: run each PR's new test files on its integrated prefix (lib/ship-queue-stack-tests.ts); the command's words when `--stack-tests-command` names one. */ stackTests?: string[] | true; + /** Stamping S_i runs the base policy's restamp_command or release_tool (changelog_fragments trains). */ + allowRepoCommands?: boolean; } /** `--order ,,...`: every pinned PR exactly once (a leading # allowed). */ @@ -412,13 +424,17 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none'); const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed'); if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines }; - const head = chain.prefixes.at(-1)!; + const policy = env.policy.policy; + const stamped = policy.changelog_fragments ? stampChain({ repoRoot: env.ctx.repoRoot, base: env.ctx.base, policy, policyLabel: env.policy.source.label, baseSha, slots: order.train, plain: chain.prefixes, allowRepoCommands: !!o.allowRepoCommands, now: new Date(nowIso()) }) : null; + if (stamped && 'code' in stamped) return outFailed(stamped, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} --allow-repo-commands (after the named slot is fixed or dropped)`, 'none'); + if (stamped) lines.push(...stamped.lines); + const head = stamped ? stamped.stamped.at(-1)! : chain.prefixes.at(-1)!; const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' }); const now = nowIso(); return { lines, t: { schema: 1, kind: 'train', nonce, key, repo_key: env.repo.key, repo: env.repo.url, base: { ref: env.ctx.base, sha: baseSha }, policy: env.policy.source.label, slots: order.train, held: order.held.map(s => s.pr), prefixes: chain.prefixes, head: head.commit, tree: head.tree, branch: `train/${nonce}`, - urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null, + urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), ...(stamped ? { stamped: stamped.stamped } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null, created_at: now, updated_at: now, } }; } diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 68ae3cf26..73c309fa0 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -148,12 +148,12 @@ export function latestRunPerName } { +export function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean; checks: Array<{ name: string; status: string; conclusion: string | null }>; contexts: Array<{ name: string; state: string }> } { const at = sha.slice(0, 12); const runs = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs'); - if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false, checks: [] }; + if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false, checks: [], contexts: [] }; const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses'); - if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false, checks: [] }; + if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false, checks: [], contexts: [] }; const checks = latestRunPerName(runs.items).map(c => ({ name: String(c.name ?? ''), status: String(c.status ?? ''), conclusion: c.conclusion === undefined || c.conclusion === null ? null : String(c.conclusion) })); const verdicts = [ ...checks.map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'), @@ -162,7 +162,7 @@ export function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: const count = (v: string) => verdicts.filter(x => x === v).length; const [passed, failed, pending, skipped] = [count('passed'), count('failed'), count('pending'), count('skipped')]; const green = verdicts.length > 0 && pending === 0 && failed === 0; - return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0, checks }; + return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0, checks, contexts: statuses.items.map(s => ({ name: String(s.context ?? ''), state: String(s.state ?? '') })) }; } /** diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index 9ef0790e5..90a4f7ff3 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -119,7 +119,7 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: const second = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.head}^2`])?.trim(); const pin = second ? `${o.head}^1` : mergeBase; if (fragDir && (!second || git(o.repoRoot, ['merge-base', '--is-ancestor', second, base]) === null)) return { kind: 'invalid', reason: `under changelog_fragments only the merge step stamps (a commit with parents [pin, ${base}]); leave VERSION, package.json and the release outputs at the merge base's values and add a fragment` }; - const folded = fragDir ? readFragments(o.repoRoot, pin, fragDir, base) : null; + const folded = fragDir ? readFragments(o.repoRoot, pin, fragDir, base, typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md', 'skip') : null; if (folded?.errors.length) return { kind: 'invalid', reason: `${folded.errors[0]!.path}: ${folded.errors[0]!.message}` }; const level = policy.bump === 'patch' ? 'patch' : 'micro'; const expected = bumpVersion(baseVersion, folded ? effectiveBump(level, folded.fragments) : (policy.bump ?? 'patch')); diff --git a/package.json b/package.json index 08361351e..05a22f13b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.338", + "version": "1.91.339", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 30a1a3368..6687ef3c2 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -291,6 +291,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> reason: 'spawns bin/gstack-ship-queue (shebang) and a #!/bin/sh gh shim through test/helpers/restamp-fixture.ts and test/helpers/ship-queue-fixture.ts', }, // GSTA-49 T1 (merge trains): the train lib logic runs on Windows in test/ship-queue-train-logic.test.ts. + { + file: 'test/ship-queue-train-land.test.ts', + reason: 'drives train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)', + }, { file: 'test/ship-queue-train.test.ts', reason: 'drives bin/gstack-ship-queue against the stateful #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard run 39152702056)', diff --git a/scripts/train-backstop.ts b/scripts/train-backstop.ts index 935353550..5f7a0db1a 100644 --- a/scripts/train-backstop.ts +++ b/scripts/train-backstop.ts @@ -53,8 +53,11 @@ function noteRecords(git: Git, ki }); } -/** The newest green-train record whose prefix trees include this digest, with its run; null when none. */ -export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, digest: string, lines: string[]): { run: Run; record: TrainRecord; index: number } | null { +/** + * The newest green-train record with a prefix covering main, with its run; null when none. A plain prefix T_i + * covers main's tree modulo stamps (its digest); a stamped prefix S_i covers only main's exact tree. + */ +export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, main: { digest: string | null; tree: string }, lines: string[]): { run: Run; record: TrainRecord; index: number; exact: boolean } | null { const runs = json<{ workflow_runs?: Run[] }>(gh(['api', '-X', 'GET', `repos/${repo}/actions/workflows/${train.gate_workflow}/runs`, '-f', 'event=workflow_dispatch', '-f', 'status=success', '-f', 'per_page=50'], GH_MS)); if (!runs) { lines.push('cannot list the train gate runs; the backstop gates main'); return null; } const trains = (runs.workflow_runs ?? []).map(run => ({ run, nonce: /\[eval-bg ([A-Za-z0-9._-]+)\]/.exec(run.display_title ?? '')?.[1] ?? '' })) @@ -69,7 +72,7 @@ export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, d try { record = file ? JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) as TrainRecord : null; } catch { record = null; } if (!record || record.kind !== 'train-gate' || record.verdict !== 'GREEN' || record.census?.complete !== true || record.nonce !== nonce || record.head_sha !== run.head_sha || !Array.isArray(record.prefixes)) continue; - const hit = record.prefixes.find(p => p.stamp_free_tree === digest); + const hit = record.prefixes.find(p => p.exact ? p.tree === main.tree : main.digest !== null && p.stamp_free_tree === main.digest); if (!hit) continue; const checks = json<{ check_runs?: Array<{ name?: string; conclusion?: string | null }> }>( gh(['api', `repos/${repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(train.full_gate_check)}`], GH_MS)); @@ -77,7 +80,7 @@ export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, d lines.push(`train ${nonce} (run ${run.id}) lists this tree but its ${train.full_gate_check} check run is not a success`); continue; } - return { run, record, index: hit.index }; + return { run, record, index: hit.index, exact: !!hit.exact }; } return null; } finally { @@ -150,9 +153,10 @@ export function runBackstop(o: BackstopOptions): { action: BackstopAction; lines } const digest = stampFreeTree(git, o.sha, o.repoRoot, loaded.policy); if (typeof digest !== 'string') { lines.push(`BACKSTOP: no tree modulo stamps (${digest.error}); gating main`); } - const covered = typeof digest === 'string' ? coveringRecord(gh, o.repo, train, digest, lines) : null; + const tree = git(['rev-parse', `${o.sha}^{tree}`]).out; + const covered = coveringRecord(gh, o.repo, train, { digest: typeof digest === 'string' ? digest : null, tree }, lines); if (covered) { - lines.push(`BACKSTOP: covered: main ${o.sha.slice(0, 12)} is T${covered.index} of green train ${covered.record.nonce} (run ${covered.run.id}, tree modulo stamps ${String(digest).slice(0, 12)}); no gate`); + lines.push(`BACKSTOP: covered: main ${o.sha.slice(0, 12)} is ${covered.exact ? `S${covered.index}` : `T${covered.index}`} of green train ${covered.record.nonce} (run ${covered.run.id}, ${covered.exact ? `exact tree ${tree.slice(0, 12)}` : `tree modulo stamps ${String(digest).slice(0, 12)}`}); no gate`); return done('covered'); } const owned = noteRecords(git, 'freeze', repoKey).find(f => !f.cleared_at && f.main.sha === o.sha && f.backstop); diff --git a/scripts/train-gate.ts b/scripts/train-gate.ts index b5b433feb..1613dfe2b 100644 --- a/scripts/train-gate.ts +++ b/scripts/train-gate.ts @@ -49,7 +49,7 @@ export const MAX_BISECT_CASES = 40; /** A full gate's recorded cost (MEASUREMENT.md: $90.93 to $94.70; lib/gate-cost.ts uses the same scale). */ export const FULL_GATE_USD = 93.14; /** T1's prefix commits: `train T: merge # at ` (lib/ship-queue-train.ts buildChain). */ -const PREFIX_SUBJECT = /^train T(\d+): merge #(\d+) at [0-9a-f]{12}$/; +const PREFIX_SUBJECT = /^train (?:T(\d+): merge #(\d+) at [0-9a-f]{12}|S(\d+): merge #(\d+) at [0-9a-f]{12} v[0-9.]+)$/; export type Git = (args: string[]) => { ok: boolean; out: string }; export const gitIn = (cwd: string): Git => (args) => { @@ -185,9 +185,10 @@ export function stampFreeTree(git: Git, commit: string, repoRoot: string, policy return createHash('sha256').update(lines.sort().join('\n')).digest('hex'); } -export interface TrainPrefixTree { index: number; commit: string; tree: string; stamp_free_tree: string } +/** `exact`: a stamped prefix S_i (changelog_fragments trains), which covers only its exact tree. */ +export interface TrainPrefixTree { index: number; commit: string; tree: string; stamp_free_tree: string; exact?: true } -/** T1..Tk from the train head, walking first parents over T1's prefix commits. */ +/** T1..Tk (or stamped S1..Sk, never mixed) from the train head, walking first parents over the prefix commits. */ export function trainPrefixes(git: Git, head: string, repoRoot: string, policy: ShipPolicy): TrainPrefixTree[] | { error: string } { const log = git(['log', '--first-parent', `--max-count=${MAX_PREFIXES}`, '--format=%H%x00%P%x00%s', head]); if (!log.ok) return { error: `cannot walk ${head.slice(0, 12)}'s first parents (${log.out.split('\n')[0]})` }; @@ -196,11 +197,13 @@ export function trainPrefixes(git: Git, head: string, repoRoot: string, policy: const [commit, parents, subject] = line.split('\0') as [string, string, string]; const m = PREFIX_SUBJECT.exec(subject ?? ''); if (!m || parents.split(' ').length !== 2) break; - const index = Number(m[1]); + const index = Number(m[1] ?? m[3]); + const exact = m[3] !== undefined; + if (found.length && exact !== !!found[0]!.exact) return { error: `${commit.slice(0, 12)} mixes stamped and plain prefixes` }; if (found.length && index !== found.at(-1)!.index - 1) return { error: `prefix T${index} follows T${found.at(-1)!.index} at ${commit.slice(0, 12)}` }; const digest = stampFreeTree(git, commit, repoRoot, policy); if (typeof digest !== 'string') return digest; - found.push({ index, commit, tree: git(['rev-parse', `${commit}^{tree}`]).out, stamp_free_tree: digest }); + found.push({ index, commit, tree: git(['rev-parse', `${commit}^{tree}`]).out, stamp_free_tree: digest, ...(exact ? { exact: true as const } : {}) }); } if (!found.length || found.at(-1)!.index !== 1) return { error: `${head.slice(0, 12)} is not a train head (no T1..Tk prefix chain)` }; return found.reverse(); diff --git a/test/changelog-fragments.test.ts b/test/changelog-fragments.test.ts index a35b99d90..f02c72f7e 100644 --- a/test/changelog-fragments.test.ts +++ b/test/changelog-fragments.test.ts @@ -87,6 +87,11 @@ describe('policy key', () => { expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: '../x' } }).map(e => e.path)).toContain('changelog_fragments.dir'); expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'changelog.d/*' } }).map(e => e.path)).toContain('changelog_fragments.dir'); expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', glob: '*' } }).map(e => e.path)).toContain('changelog_fragments.glob'); + expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', required_checks: ['free-tests', 'windows-free-shard'] } })).toEqual([]); + for (const bad of [[], ['a', 'a'], [''], ['x\ny'], 'free-tests']) { + if (Array.isArray(bad) && !bad.length) continue; + expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', required_checks: bad } }).map(e => e.path)).toContain('changelog_fragments.required_checks'); + } const { min_gstack_version: _drop, ...noMin } = POLICY; expect(validatePolicy(noMin).map(e => e.path)).toContain('changelog_fragments'); }); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts new file mode 100644 index 000000000..dbc240bec --- /dev/null +++ b/test/ship-queue-train-land.test.ts @@ -0,0 +1,253 @@ +/** + * Stamped trains (changelog_fragments; GSTA-154 PR B): train build makes S_1..S_k beside T_1..T_k and gates + * S_k; train land publishes each slot's S_i exactly (a tool-made stamp commit on [pin, main], pushed under + * the pin's lease, labelled evals-no-debounce), confirms main is S_i, and freezes on any other main. + * Fixture repos with a bare origin and the train gh shim, wrapped for pr edit, pr merge and the PR's checks. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup } from './helpers/restamp-fixture'; +import { + GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture, +} from './helpers/ship-queue-train-fixture'; + +const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const fixtures: TrainFixture[] = []; +afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); + +/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */ +function wrap(fx: TrainFixture): void { + const dir = path.join(fx.repo.root, 'gh-land-wrap'); + fs.mkdirSync(dir, { recursive: true }); + const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js'); + fs.writeFileSync(path.join(dir, 'gh'), [ + '#!/usr/bin/env node', + "const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);", + "const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));", + "const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();", + "const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};", + "if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}", + " const before=rev('main');let tree=rev(want+'^{tree}');", + " if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();", + " const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();", + " g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}", + " g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}", + "const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');", + "if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}", + `const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`, + '', + ].join('\n'), { mode: 0o755 }); + fx.env.PATH = `${dir}:${fx.env.PATH}`; + (fx.env as Record).LAND_WRAP_DATA = fx.dataFile; +} + +function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string { + const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false }); + fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`); + readyReceipt(fx, pr, head); + return head; +} +function stampedTrain(gate = GREEN_GATE, policy: Record = POLICY) { + const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); + const h1 = fragmentPr(fx, '1'); + const h2 = fragmentPr(fx, '2', '---\nbump: minor\n---\n- #2 change\n'); + fx.write(d => { d.rules['evals.yml'] = gate; }); + const built = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]); + const nonce = nonceOf(built.out); + return { fx, h1, h2, built, nonce }; +} +const land = (fx: TrainFixture, nonce: string, pr: string) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait']); +const originMain = (fx: TrainFixture) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main'); + +describe('stamped trains: build and land', () => { + test('build records S1, S2 and gates S2; land publishes each slot exactly, with the label, and records the merge', () => { + const { fx, h1, h2, built, nonce } = stampedTrain(); + expect(built.status, built.err).toBe(EXIT.ok); + expect(built.out).toMatch(/^S1: #1 v1\.2\.4\.0 [0-9a-f]{12} /m); + expect(built.out).toMatch(/^S2: #2 v1\.3\.0\.0 [0-9a-f]{12} /m); + const t = trainJson(fx, nonce); + expect(t.stamped.map((s: any) => [s.pr, s.version])).toEqual([['1', '1.2.4.0'], ['2', '1.3.0.0']]); + expect(t.head).toBe(t.stamped[1].commit); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + + const verify = queue(fx, ['verify', '--pr', '1', '--train', nonce]); + expect(verify.err).toMatch(/has stamped prefixes.*\(QUEUE_GATE_MISSING\)/); + expect(line(verify.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 1`); + const early = land(fx, nonce, '2'); + expect(early.err).toMatch(/main is the train base; land #1 \(slot 1\) first.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + const pushed = land(fx, nonce, '1'); + expect(pushed.out, pushed.err).toContain('LABEL: evals-no-debounce added'); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!; + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(stamp); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-list', '--parents', '-n', '1', stamp).split(' ').slice(1)).toEqual([h1, originMain(fx)]); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}')).toBe(t.stamped[0].tree); + const merged = land(fx, nonce, '1'); + expect(merged.status, merged.err).toBe(EXIT.ok); + expect(merged.out).toContain("main's tree is S1 exactly"); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(t.stamped[0].tree); + land(fx, nonce, '2'); + const second = land(fx, nonce, '2'); + expect(second.status, second.err).toBe(EXIT.ok); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'show', 'main:VERSION')).toBe('1.3.0.0'); + expect(trainJson(fx, nonce)).toMatchObject({ state: 'merged' }); + expect(fx.read().prs['2']).toMatchObject({ state: 'MERGED' }); + expect((fx.read() as any).labels).toEqual({ 1: 'evals-no-debounce', 2: 'evals-no-debounce' }); + void h2; + }, 300_000); + + test('a foreign push to the PR branch is never overwritten; a merge outside the train freezes the queue', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + fx.repo.git('push', '-q', '-f', 'origin', 'origin/main:refs/heads/pr-1'); + const moved = land(fx, nonce, '1'); + expect(moved.err).toContain('(QUEUE_TRAIN_MOVED)'); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main')); + + const second = stampedTrain(); + expect(queue(second.fx, ['train', 'verdict', '--train', second.nonce]).status).toBe(EXIT.ok); + land(second.fx, second.nonce, '1'); + expect(land(second.fx, second.nonce, '1').status).toBe(EXIT.ok); + land(second.fx, second.nonce, '2'); + second.fx.write(d => { (d as any).outsideOnMerge = true; }); + const drift = land(second.fx, second.nonce, '2'); + expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)'); + expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/); + }, 300_000); + + test('a record that is not the gated head\'s first-parent chain (any slot\'s commit, tree or pin) pushes nothing, merges nothing and leaves the PR branch in place', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const file = path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); + const t = trainJson(fx, nonce); + const base = t.prefixes[0]; + const [s1, s2] = t.stamped; + const forged = [ + { ...t, stamped: [{ ...s1, commit: h1, tree: fx.repo.git('rev-parse', h1 + '^{tree}') }, s2] }, + { ...t, stamped: [{ ...s1, tree: 'f'.repeat(40) }, s2] }, + { ...t, stamped: [{ ...s2 }, s2] }, + { ...t, stamped: [{ ...s1, index: 0, commit: base.commit, tree: base.tree }, s2] }, + { ...t, stamped: [s1, { ...s2, tree: base.tree }] }, + { ...t, stamped: [s1] }, + { ...t, slots: [{ ...t.slots[0], head: t.slots[1].head }, t.slots[1]] }, + ]; + for (const record of forged) { + fs.writeFileSync(file, JSON.stringify(record)); + const r = land(fx, nonce, '1'); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/are not the first-parent chain of the gated head.*\(QUEUE_PROOF_MISSING\)/); + expect(r.out).not.toMatch(/^PUSHED:/m); + } + expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' }); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); + }, 180_000); + + test('a rerun after the stamp push but before the journal recognizes its own stamp and merges', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const pushed = land(fx, nonce, '1'); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!; + fs.rmSync(path.join(fx.state, 'ship-queue', 'trains', nonce, 'land-1.json')); + const resumed = land(fx, nonce, '1'); + expect(resumed.status, resumed.err).toBe(EXIT.ok); + expect(resumed.out).toContain("main's tree is S1 exactly"); + expect(fx.read().prs['1']).toMatchObject({ state: 'MERGED' }); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}')); + }, 300_000); + + test('a rerun after the journal but before the stamp push pushes the stamp instead of stopping', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(land(fx, nonce, '1').out)![1]!; + fx.repo.git('push', '-q', '-f', 'origin', `${h1}:refs/heads/pr-1`); + const again = land(fx, nonce, '1'); + expect(again.err).not.toContain('QUEUE_TRAIN_MOVED'); + expect(again.out).toContain(`PUSHED: ${stamp}`); + expect(land(fx, nonce, '1').status).toBe(EXIT.ok); + }, 300_000); + + test('a slot landed out of order after an earlier slot merged refuses without freezing and names the next slot; a rerun of a landed slot confirms', () => { + const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); + const heads = ['1', '2', '3'].map(pr => fragmentPr(fx, pr)); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + land(fx, nonce, '1'); + expect(land(fx, nonce, '1').status).toBe(EXIT.ok); + const early = land(fx, nonce, '3'); + expect(early.err).toMatch(/main is S1; land #2 \(slot 2\) first.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + expect(early.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + expect(line(early.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 2`); + land(fx, nonce, '2'); + expect(land(fx, nonce, '2').status).toBe(EXIT.ok); + const rerun = land(fx, nonce, '1'); + expect(rerun.status, rerun.err).toBe(EXIT.ok); + expect(rerun.out).toContain('is S2, past S1'); + expect(rerun.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + }, 300_000); + + test('a failed stamp push with the branch still at the pin fails retryably instead of reporting a move', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive'); + fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const failed = land(fx, nonce, '1'); + expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/); + expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED'); + fs.rmSync(hook); + expect(land(fx, nonce, '1').out).toMatch(/^PUSHED: [0-9a-f]{40}/m); + }, 300_000); + + test('a required check that never reported on the stamp commit holds the merge and names the check', () => { + const { fx, nonce } = stampedTrain(GREEN_GATE, { ...POLICY, changelog_fragments: { dir: 'changelog.d', required_checks: ['free-tests', 'windows-free-shard'] } }); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(land(fx, nonce, '1').out)![1]!; + const held = land(fx, nonce, '1'); + expect(held.err).toMatch(/required checks missing or not green on the stamp commit \S+: windows-free-shard; fix: wait for windows-free-shard to pass on \S+ \(dispatch the workflow on pr-1 if it never ran\).*\(QUEUE_NOT_READY\)/); + expect(held.out).toContain(`REQUIRED_CHECKS: not green at ${stamp.slice(0, 12)}: windows-free-shard`); + expect(line(held.out, 'NEXT:')).toMatch(/^NEXT: wait for windows-free-shard to pass on /); + expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' }); + }, 300_000); + + test('a PR retargeted off the train base refuses before any push', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + fx.write(d => { (d.prs['1'] as any).baseRefName = 'release'; }); + const r = land(fx, nonce, '1'); + expect(r.err).toMatch(/targets release, not main.*\(QUEUE_NOT_READY\)/); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); + }, 180_000); + + test('a red stamped train bisects its stamped chain: T0 and S1..Sk, each an ancestor of the train head', () => { + const { fx, nonce } = stampedTrain(RED_GATE); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.fail); + const dry = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5', '--dry-run']); + expect(dry.err).not.toContain('QUEUE_TRAIN_PREFIX_INVALID'); + expect(dry.out).toContain('BISECT: cases c1 prefixes T0,T1,T2 need results'); + }, 180_000); + + test('the receipt check is the depth-checked one train build uses: a receipt whose writer records depth but which records none refuses, nothing pushed', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + fx.write(d => { (d.prs['1'] as any).body = shippedBody('1', h1, { gstack_version: '1.91.307.0' }); }); + const r = land(fx, nonce, '1'); + expect(r.err).toContain('(QUEUE_RECEIPT_DEPTH_RAISED)'); + expect(r.out).toMatch(/^REVIEW_DEPTH: receipt=none required=/m); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); + }, 180_000); + + test('a plain train refuses train land and keeps the slot-stamp path', () => { + const fx = trainFixture(TRAIN_POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); + const h1 = openPr(fx, '1'); + readyReceipt(fx, '1', h1); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out); + expect(trainJson(fx, nonce).stamped).toBeUndefined(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const r = land(fx, nonce, '1'); + expect(r.err).toMatch(/was built without stamped prefixes.*\(QUEUE_GATE_MISSING\)/); + }, 180_000); +}); diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts new file mode 100644 index 000000000..37caacac4 --- /dev/null +++ b/test/ship-queue-train-stamp.test.ts @@ -0,0 +1,139 @@ +/** + * Stamped train prefixes (lib/ship-queue-train-stamp.ts): S_i = stamp(merge(S_(i-1), pin_i)) keeps every + * earlier release, folds only slot i's own fragments (stacked pins included), runs the versions in ORDER + * with each slot's effective bump, and a tampered earlier entry or a product change is refused. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { mergePolicy } from '../lib/ship-policy'; +import { buildChain } from '../lib/ship-queue-train'; +import { stampChain, stampMismatch, type StampChainOptions } from '../lib/ship-queue-train-stamp'; +import type { TrainSlot } from '../lib/ship-queue-train-record'; +import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; + +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +function pin(r: FixtureRepo, name: string, from: string, fragment: string): string { + r.git('checkout', '-q', '-B', name, from); + fs.writeFileSync(path.join(r.work, name + '.txt'), name + '\n'); + fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(r.work, 'changelog.d', name + '.md'), fragment); + r.git('add', '-A'); r.git('commit', '-q', '-m', name); + return r.git('rev-parse', 'HEAD'); +} +function chain(r: FixtureRepo, heads: Array<[string, string]>): StampChainOptions { + const baseSha = r.git('rev-parse', 'origin/main'); + const slots = heads.map(([pr, head]) => ({ pr, head, ready_at: '2026-10-29T00:00:00Z', screen: { mode: 'none' } }) as unknown as TrainSlot); + const policy = mergePolicy(POLICY, 'test').policy; + const built = buildChain(r.work, baseSha, slots, policy); + if (!('prefixes' in built)) throw new Error('chain conflicted'); + return { repoRoot: r.work, base: 'main', policy, policyLabel: 'test', baseSha, slots, plain: built.prefixes, allowRepoCommands: false, now: new Date('2026-10-29T12:00:00Z') }; +} +const show = (r: FixtureRepo, rev: string) => r.git('show', rev); + +describe('stamped train prefixes', () => { + test('independent pins: S2 keeps S1\'s release, versions run in order with each slot\'s bump, fragments are gone', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + const b = pin(r, 'b', 'origin/main', '---\nbump: minor\n---\n- b change\n'); + const o = chain(r, [['1', a], ['2', b]]); + const out = stampChain(o); + if ('code' in out) throw new Error(out.message); + expect(out.stamped.map(s => s.version)).toEqual(['1.2.4.0', '1.3.0.0']); + const s2 = out.stamped[1]!.commit; + expect(show(r, s2 + ':VERSION')).toBe('1.3.0.0'); + const cl = show(r, s2 + ':CHANGELOG.md'); + expect(cl.indexOf('## [1.3.0.0] - 2026-10-29')).toBeLessThan(cl.indexOf('## [1.2.4.0] - 2026-10-29')); + expect(cl).toContain('- a change'); + expect(cl).toContain('- b change'); + expect(r.git('ls-tree', '-r', '--name-only', s2, '--', 'changelog.d')).toBe(''); + expect(r.git('rev-parse', s2 + '^1')).toBe(out.stamped[0]!.commit); + expect(r.git('rev-parse', s2 + '^2')).toBe(b); + }, 60_000); + + test('stacked pins: the child folds only its own fragment; the parent\'s entry appears once', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const p = pin(r, 'p', 'origin/main', '- parent change\n'); + const c = pin(r, 'c', p, '- child change\n'); + const out = stampChain(chain(r, [['1', p], ['2', c]])); + if ('code' in out) throw new Error(out.message); + const cl = show(r, out.stamped[1]!.commit + ':CHANGELOG.md'); + expect(cl.split('- parent change').length).toBe(2); + const top = cl.slice(cl.indexOf('## [1.2.5.0]'), cl.indexOf('## [1.2.4.0]')); + expect(top).toContain('- child change'); + expect(top).not.toContain('- parent change'); + }, 60_000); + + test('a base with a four-part package.json listed in stamp_files keeps four parts in every S_i, read from S_(i-1)', () => { + const policy = { ...POLICY, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', 'changelog.d/*.md'], stamp_patterns: ['"version": "{version}"'] }; + const r = makeFixtureRepo({ policy, prefix: 'train-stamp-' }); repos.push(r); + const pkg = JSON.parse(fs.readFileSync(path.join(r.work, 'package.json'), 'utf8')); + fs.writeFileSync(path.join(r.work, 'package.json'), JSON.stringify({ ...pkg, version: '1.2.3.0' }, null, 2) + '\n'); + fs.rmSync(path.join(r.work, 'package-lock.json')); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'four-part manifest'); r.git('push', '-q', 'origin', 'main'); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + const b = pin(r, 'b', 'origin/main', '- b change\n'); + const o = { ...chain(r, [['1', a], ['2', b]]), policy: mergePolicy(policy, 'test').policy }; + const out = stampChain(o); + if ('code' in out) throw new Error(out.message); + expect(out.stamped.map(s => JSON.parse(show(r, s.commit + ':package.json')).version)).toEqual(['1.2.4.0', '1.2.5.0']); + }, 60_000); + + test('a pin\'s manifest edit beside the version line survives the predecessor\'s bump', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + r.git('checkout', '-q', '-B', 'b', 'origin/main'); + const file = path.join(r.work, 'package.json'); + fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), name: 'fixture-b' }, null, 2) + '\n'); + fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(r.work, 'changelog.d', 'b.md'), '- b change\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'b'); + const out = stampChain(chain(r, [['1', a], ['2', r.git('rev-parse', 'HEAD')]])); + if ('code' in out) throw new Error(out.message); + const s1 = JSON.parse(show(r, out.stamped[0]!.commit + ':package.json')); + const s2 = JSON.parse(show(r, out.stamped[1]!.commit + ':package.json')); + expect(s1.version).toBe('1.2.4'); + expect(s2).toMatchObject({ name: 'fixture-b', version: '1.2.5', dependencies: { 'left-pad': '1.2.3' } }); + }, 60_000); + + test('a pin whose CHANGELOG is a symlink refuses before the stamp writes anything outside', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const outside = path.join(r.root, 'outside.md'); + fs.writeFileSync(outside, 'keep\n'); + r.git('checkout', '-q', '-B', 'l', 'origin/main'); + fs.rmSync(path.join(r.work, 'CHANGELOG.md')); + fs.symlinkSync(outside, path.join(r.work, 'CHANGELOG.md')); + fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(r.work, 'changelog.d', 'l.md'), '- l change\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'l'); + const out = stampChain(chain(r, [['1', r.git('rev-parse', 'HEAD')]])); + expect('code' in out ? out.message : 'stamped').toMatch(/CHANGELOG\.md is a symlink in the merged tree/); + expect(fs.readFileSync(outside, 'utf8')).toBe('keep\n'); + }, 60_000); + + test('a tampered earlier version or a product change in S_i is refused', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + const b = pin(r, 'b', 'origin/main', '- b change\n'); + const o = chain(r, [['1', a], ['2', b]]); + const out = stampChain(o); + if ('code' in out) throw new Error(out.message); + const s1 = out.stamped[0]!.commit; + const s2 = out.stamped[1]!; + const merged = r.git('commit-tree', r.git('merge-tree', '--write-tree', b, s1).split('\n')[0]!, '-p', s1, '-p', b, '-m', 'm'); + expect(stampMismatch(o, merged, s2.commit, o.plain[2]!.tree)).toBeNull(); + const tamper = (file: string, edit: (t: string) => string) => { + r.git('checkout', '-q', '-f', '--detach', s2.commit); + fs.writeFileSync(path.join(r.work, file), edit(fs.readFileSync(path.join(r.work, file), 'utf8'))); + r.git('add', '-A'); + return r.git('commit-tree', r.git('write-tree'), '-p', s1, '-p', b, '-m', 'tampered'); + }; + const older = tamper('CHANGELOG.md', t => t.replace('## [1.2.4.0]', '## [1.2.3.9]')); + expect(stampMismatch(o, merged, older, o.plain[2]!.tree)).toContain('the stamp rewrote or dropped the earlier release entry [1.2.4.0] in CHANGELOG.md'); + const product = tamper('a.txt', () => 'not a\n'); + expect(stampMismatch(o, merged, product, o.plain[2]!.tree)).toContain('a.txt'); + }, 60_000); +}); diff --git a/test/train-backstop.test.ts b/test/train-backstop.test.ts index b71244faf..ecdf6aeed 100644 --- a/test/train-backstop.test.ts +++ b/test/train-backstop.test.ts @@ -223,3 +223,50 @@ describe('backstop: debounce while a train merges', () => { expect(dispatches()).toEqual([]); }); }); + +describe('backstop: a stamped train (changelog_fragments) covers only exact trees', () => { + test('main equal to S1 is covered as S1; the same tree with another VERSION is gated', () => { + const fx = trainFixture(); + const { repo, main0, prs } = fx; + let parent = main0; + const stamped = prs.slice(0, 2).map((head, i) => { + repo.git('checkout', '-q', '-f', '--detach', parent); + repo.git('merge', '-q', '--no-ff', '--no-commit', head); + fs.writeFileSync(path.join(repo.work, 'VERSION'), `1.2.${4 + i}.0\n`); + repo.git('add', '-A'); + parent = repo.git('commit-tree', repo.git('write-tree'), '-p', parent, '-p', head, '-m', `train S${i + 1}: merge #${101 + i} at ${head.slice(0, 12)} v1.2.${4 + i}.0`); + repo.git('merge', '--abort'); + return parent; + }); + repo.git('checkout', '-q', '-f', 'main'); + const prefixes = trainPrefixes(gitIn(repo.work), parent, repo.work, loadPolicyFromHead(repo.work).policy); + if (!Array.isArray(prefixes)) throw new Error(prefixes.error); + expect(prefixes.map(p => [p.index, p.exact])).toEqual([[1, true], [2, true]]); + const record = { ...fx.record, head_sha: parent, tree: repo.git('rev-parse', `${parent}^{tree}`), prefixes }; + const push = (tree: string) => { + const sha = repo.git('commit-tree', tree, '-p', repo.git('rev-parse', 'main'), '-m', 'squash'); + repo.git('update-ref', 'refs/heads/main', sha); + repo.git('push', '-q', 'origin', 'main'); + return sha; + }; + const s1 = push(repo.git('rev-parse', `${stamped[0]}^{tree}`)); + const covered = backstop({ ...fx, record }, s1, fakeGh({ ...fx, record }, { record }).gh); + expect(covered.action, covered.lines.join('\n')).toBe('covered'); + expect(covered.lines.at(-1)).toContain(`is S1 of green train ${TRAIN_NONCE} (run 900, exact tree`); + repo.git('checkout', '-q', '-f', '--detach', stamped[0]!); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.9.0\n'); + repo.git('add', '-A'); + const moduloStamps = push(repo.git('write-tree')); + repo.git('checkout', '-q', '-f', 'main'); + const gated = backstop({ ...fx, record }, moduloStamps, fakeGh({ ...fx, record }, { record, gate: 'success' }).gh); + expect(gated.lines.join('\n')).toContain("no green-train record lists main's tree"); + expect(gated.action).not.toBe('covered'); + }); + + test('a chain that mixes stamped and plain prefixes is not a train head', () => { + const fx = trainFixture(); + const { repo, train, prs } = fx; + const s = repo.git('commit-tree', repo.git('rev-parse', `${train[0]}^{tree}`), '-p', train[0]!, '-p', prs[1]!, '-m', `train S2: merge #102 at ${prs[1]!.slice(0, 12)} v1.2.5.0`); + expect(trainPrefixes(gitIn(repo.work), s, repo.work, loadPolicyFromHead(repo.work).policy)).toEqual({ error: `${train[0]!.slice(0, 12)} mixes stamped and plain prefixes` }); + }); +}); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index 8f65a748f..76eb21081 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -97,6 +97,32 @@ describe('version gate: changelog fragments', () => { expect(wrong.out).toContain('must carry v1.3.0.0'); }); + test('a stacked child\'s stamp counts only its own fragment once its parent has landed', () => { + const r = fixture(); + const release = (version: string, line: string) => write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n\n', '# Changelog\n\n## [' + version + '] - 2026-10-29\n\n' + line + '\n\n')); + r.git('checkout', '-q', '-b', 'p'); + write(r, 'changelog.d/p.md', '---\nbump: minor\n---\n- p\n'); + commit(r, 'p'); + r.git('checkout', '-q', '-b', 'c'); + write(r, 'changelog.d/c.md', '- c\n'); + const child = commit(r, 'c'); + r.git('checkout', '-q', '-B', 'main', 'origin/main'); + write(r, 'VERSION', '1.3.0.0\n'); + release('1.3.0.0', '- p'); + commit(r, 'v1.3.0.0 (#p)'); + r.git('push', '-q', 'origin', 'main'); + r.git('checkout', '-q', '-f', '--detach', child); + fs.rmSync(path.join(r.work, 'changelog.d'), { recursive: true }); + r.git('checkout', 'origin/main', '--', 'CHANGELOG.md'); + write(r, 'VERSION', '1.3.1.0\n'); + release('1.3.1.0', '- c'); + r.git('add', '-A'); + const stamp = r.git('commit-tree', r.git('write-tree'), '-p', child, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v1.3.1.0 stamp'); + const res = run(r, stamp); + expect(res.code, res.out).toBe(0); + expect(res.out).toContain('carries v1.3.1.0'); + }); + test('the workflow runs on every PR (no path filter), so a PR without a release file still proves its fragment', () => { const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any; expect(workflow.on.pull_request.paths).toBeUndefined();