mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.339.0 ship-queue: stamped merge-train prefixes and train land (GSTA-154 PR B) (#3595)
* feat(ship-policy): changelog_fragments key; restamp folds fragments; tree receipt accepts the fold Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): land, the per-PR merge step; Version Gate enforces fragments Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): land --repair after drift; hook base sha; docs and CHANGELOG Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: timeout on the land gh shim's git spawn Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): /ship writes a CHANGELOG fragment under changelog_fragments; land checks the depth-checked receipt (#3567) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(land): keeps the base's four-part package.json (#3557) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): land waits for CI through runScriptSync (script-invocation tripwire) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: land, fragment and Version Gate cases from /ship's coverage audit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): leave test/ship-queue-land.test.ts out of the Windows-safe subset (gh shim cannot launch there) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): rollback lands open fragment PRs first; restamp folds only with the key on Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(land): close pre-landing review findings land without --no-wait now compares the pushed branch head and polls the stamp commit's checks, refuses a PR whose base is not the stamped base, releases the lock on a pre-push failure, keeps --break-lock off drift locks, resumes from a lost journal via the stamp commit's pin, honours history merge-only, and refuses a fragment whose text is already released on the base. Version Gate stops applying open claims to a fragments stamp. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(land): close adversarial and outside-voice findings land reconciles a merge GitHub completed behind an error reply, refuses --break-lock against a holder that just entered a push or a merge, keeps a repair takeover on the lock itself (any checkout can land the repair PR), resumes --repair after a failed PR create, releases the lock on QUEUE_LAND_BASE_BUSY, never writes through a symlinked release file, keeps a package.json version source's other edits, and refuses an empty object id instead of pushing a deletion. A carried-back fragment is matched on whole released lines. Under changelog_fragments Version Gate accepts a stamped head only as a [pin, base] merge-step commit with valid fragments. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(land): refuse a pin without a READY receipt; symlinked release files refuse before restamp Under changelog_fragments, gstack-ship-queue ready records a READY receipt (refs/notes/gstack-trains) once its pregate, full_e2e and /ship receipt checks pass, and land refuses a pin without one (fix: run gstack-ship-queue ready --pr N), --dry-run included (GStack Minder ruling, 2026-10-29). The merged tree's release files may not be symlinks, so restamp never writes outside the scratch worktree. --repair adopts an existing branch only when it holds land's repair commit. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): clear's stamp-only substitution applies the changelog_fragments fold; docs match land and ready gstack-pregate clear and gstack-tree-receipt now classify through one classifyFiles, so under changelog_fragments a receipt for the fragment head clears the stamped head when the stamp is exactly the fold. The ship-policy reference names ready's receipt push, land's own check polling, the key-off refusal and the /ship changelog_fragments line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate, ship-policy): the fragment fold's two receipt rules, and clear applies them too Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): stamped train prefixes S_i with the self-check (PR B) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): train build stamps S_i under fragments; train land publishes S_i exactly; backstop covers exact trees (PR B) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: stamped trains and train land; QUEUE_TRAIN_STAMP_MISMATCH anchor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * captain templates: fragments instead of [Unreleased]; no slot call for stamped trains Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: timeouts on the train-land gh wrapper's sync spawns Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): train land checks the depth-checked receipt (#3567); regenerate the doctor anchors Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(train-stamp): S_i keeps the base's four-part package.json (#3557) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): leave test/ship-queue-train-land.test.ts out of the Windows-safe subset (same gh shim as the train tests) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): close pre-landing review and outside-voice findings - train land checks commit-tree and stamps with a fixed identity and date, journals before the push, and adopts its own stamp head on a rerun; a failed commit-tree never pushes a branch deletion - a symlinked release file in the merged tree refuses before the train stamp writes - mergeStep merges a conflicted manifest three-way with the predecessor's version (mergeManifest, shared with land) - train land refuses a PR retargeted off the base and a failed base refresh - Version Gate skips a released fragment carried in by a stacked pin instead of counting its bump - bisect of a stamped train tests T0 and S_1..S_k Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): a rerun whose head is still the pin re-enters the stamp step after a journaled, unpushed stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): prove the record's S_i is on the gated chain before stamping; a merge main does not show yet pends Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): pin S_i to the gated head's exact prefix; an out-of-order slot refuses without freezing; stamps never sign Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train-land): pin S_i to slot position; a failed push or unreadable PR never reads as a move; reruns of landed slots confirm; T0 in the out-of-order refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(train-land): verify the whole stamped chain from git before landing; require changelog_fragments.required_checks green on the stamp commit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy, troubleshooting, harness): train land refusals, resume and required checks; stamped bisect; three-way manifest merges Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: write the Steps 12-16 range with an ASCII hyphen so the skeleton fits maxSkeletonBytes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
53858424d0
commit
3df7484bc7
34 files changed
+983
-76
No files matched your search
@@ -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 <nonce> --pr <n>` 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' ;;
|
||||
|
||||
@@ -20,6 +20,10 @@ SHIP_RECEIPT: pr=<n> head=<sha12> version=- base=<BASE> tree=<tree> session_kind
|
||||
READY_RECEIPT: <path the tool wrote> (<local and notes-ref detail>)
|
||||
```
|
||||
|
||||
Under `changelog_fragments` (docs/ship-policy.md#changelog-fragments) the PR's entry is a
|
||||
fragment, `changelog.d/<slug>.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
|
||||
|
||||
@@ -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 <nonce> --pr <n>`
|
||||
(docs/ship-policy.md#train-land).
|
||||
|
||||
```text
|
||||
#<n> slot <X>, base <BASE sha40>, order <remaining order, e.g. 41,42,43>
|
||||
|
||||
@@ -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/<slug>.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 <nonce> --pr
|
||||
<n>` 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 <n>` 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
|
||||
|
||||
@@ -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:
|
||||
<sha>`, 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 <nonce> --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) |
|
||||
|
||||
+65
-7
@@ -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 <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> 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.
|
||||
|
||||
<a id="train-land"></a>
|
||||
### 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 <nonce> --pr <n>`, 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=<branch>:<pin>`; a foreign push is never overwritten
|
||||
(`QUEUE_TRAIN_MOVED`). The stamp commit is deterministic and journaled
|
||||
(`land-<n>.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 `<name> (…)` 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.
|
||||
|
||||
<a id="ship-before-ready"></a>
|
||||
**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 <id> stopped at #<pr> (conflicts: <files>) after
|
||||
merging #<a>,#<b>; 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 <nonce>
|
||||
```
|
||||
|
||||
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/<nonce>` (`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/<base> 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:<file>"`), 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.
|
||||
|
||||
|
||||
+36
-7
@@ -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 <n>`. 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.
|
||||
|
||||
<a id="queue-proof-missing"></a>
|
||||
### `QUEUE_PROOF_MISSING`
|
||||
@@ -4265,8 +4271,10 @@ train still building, running or merging, `train watch` on an unknown record,
|
||||
unreadable run), `verify --pr <n> --train <nonce>` 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 <file>` 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 <list without it>`; 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 <n>`; the lock was released and
|
||||
the rerun restamps from the pin.
|
||||
|
||||
<a id="queue-train-stamp-mismatch"></a>
|
||||
### `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.
|
||||
|
||||
<a id="queue-train-moved"></a>
|
||||
### `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 <n>@<new sha>,…`).
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+6
-4
@@ -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 (`<dir>/<slug>.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<PolicyKey, string> = {
|
||||
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 `<dir>/<slug>.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 `<dir>/<slug>.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<string, unknown> | 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}` });
|
||||
}
|
||||
|
||||
+34
-19
@@ -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);
|
||||
|
||||
@@ -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 <n>@<sha>[,...] [--order <n>,...] [--urgent | --repair] [--dry-run [--stack-tests [--stack-tests-command "<cmd>"]]] [--free-timeout <s>]
|
||||
[--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 <nonce> resume a train (push, free tests, dispatch) from its record
|
||||
@@ -27,6 +29,9 @@ export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...]
|
||||
train status [--train <nonce>] never spends: rebuild records from refs/notes/gstack-trains, reconcile
|
||||
unconfirmed dispatches by nonce
|
||||
train verdict --train <nonce> read the gate run and its record: GREEN, RED, INCOMPLETE or PENDING
|
||||
train land --train <nonce> --pr <n> [--no-wait] [--timeout <min>]
|
||||
(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 <nonce> --spend-cap <usd> [--dry-run]
|
||||
red cases on every prefix T0..Tk, resumable and capped
|
||||
train rebuild --train <nonce> [--infra] the one rebuild an all-green bisect earns (or the one INFRA re-dispatch)
|
||||
@@ -105,6 +110,7 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
|
||||
return guarded('train build', () => buildTrain(ctx, {
|
||||
pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
|
||||
pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, 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<numb
|
||||
return guarded('train watch', () => trainWatch(ctx, { train: nonce, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, freeTimeoutSec }));
|
||||
}
|
||||
if (sub === 'verdict') return guarded('train verdict', () => trainVerdict(ctx, { train: nonce }));
|
||||
if (sub === 'land') {
|
||||
const pr = flag(rest, '--pr');
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage('train land needs --pr <number>');
|
||||
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);
|
||||
|
||||
@@ -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<Partial<PrInfo>>(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';
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* `train land --train <nonce> --pr <n>` (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=<branch>:<pin>, 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<TrainOut> {
|
||||
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 <repair-pr>@<sha>`, 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`]);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/<nonce>'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);
|
||||
|
||||
@@ -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<TrainOut> {
|
||||
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<TrainOut> {
|
||||
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';
|
||||
|
||||
+20
-4
@@ -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 <pr>,<pr>,...`: 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,
|
||||
} };
|
||||
}
|
||||
|
||||
+4
-4
@@ -148,12 +148,12 @@ export function latestRunPerName<T extends { name?: unknown; id?: unknown; statu
|
||||
}
|
||||
|
||||
/** CI at one exact SHA: the latest check run per name (as GitHub's rollup counts) and every commit-status context; unreadable or incomplete is unknown, never green. */
|
||||
export function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean; checks: Array<{ name: string; status: string; conclusion: string | null }> } {
|
||||
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 ?? '') })) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'));
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -53,8 +53,11 @@ function noteRecords<T extends { kind: string; repo_key?: string }>(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<FreezeRecord>(git, 'freeze', repoKey).find(f => !f.cleared_at && f.main.sha === o.sha && f.backstop);
|
||||
|
||||
@@ -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<i>: merge #<pr> at <sha12>` (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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<string, string>).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<string, unknown> = 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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` });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user