mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
v1.95.0.0 feat(ship-queue): land --pin merges a READY PR at its own green head (merge: direct, GSTA-258 L2 PR 1) (#3973)
* feat(ship-queue): land --pin merges a READY PR at its own green head under merge: direct (GSTA-258 L2 PR 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-policy): keep gstack's policy on train until the merge key's release exists; regenerate doctor components; floor test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): direct land fails closed without a merge base; stamping land refuses under merge: direct; Windows curation for the gh shim test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): land --pin reads policy after fetching the base, fetches the pin before verifying, and tells cannot-verify apart from a mismatch Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): land --pin STAMPED row names .gstack/version-path Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship-queue): the merge-key floor test simulates its restamp on a tentative constant, so it holds after the stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
10cdd0ec5a
commit
26a4e59165
16 files changed
+738
-7
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [1.95.0.0] - 2026-11-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Merge each PR at its own green head (`merge: direct`).** `gstack-ship-queue land --pr <n> --pin <sha>` merges one READY PR with `--squash --match-head-commit`, with no train, no stamp commit and no extra CI round. It refuses unless the /ship receipt and the READY note are bound to the pin, every check there is green with none pending, GitHub reports the PR mergeable and clean, and the PR leaves VERSION, the `package.json` version and the CHANGELOG to the release. After the merge it checks that main's tree is the pin merged onto its parent and writes a land note. A new ship-policy key, `merge: train | direct`, turns it on, and `train build` refuses under `direct`. gstack switches its own policy to `direct` in a follow-up once this release exists, so `min_gstack_version` names a version that was actually published. Each refusal has its own code and fix in docs/troubleshooting.md, and docs/captain.md has the new runbook.
|
||||
|
||||
## [1.94.44.0] - 2026-11-03
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.94.44.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.95.0.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,
|
||||
|
||||
@@ -347,6 +347,11 @@ gstack_result_anchor() {
|
||||
QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;;
|
||||
QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;;
|
||||
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
|
||||
QUEUE_LAND_STAMPED) printf '%s' 'queue-land-stamped' ;;
|
||||
QUEUE_LAND_PIN_MOVED) printf '%s' 'queue-land-pin-moved' ;;
|
||||
QUEUE_LAND_CHECKS) printf '%s' 'queue-land-checks' ;;
|
||||
QUEUE_LAND_NOT_CLEAN) printf '%s' 'queue-land-not-clean' ;;
|
||||
QUEUE_LAND_UNVERIFIED) printf '%s' 'queue-land-unverified' ;;
|
||||
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
|
||||
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
|
||||
PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;;
|
||||
|
||||
+11
-1
@@ -14,6 +14,7 @@
|
||||
* gstack-ship-queue gate-ahead --list --pr <n>
|
||||
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
|
||||
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
|
||||
* gstack-ship-queue land --pr <n> --pin <sha> [--dry-run] (merge: direct; lib/ship-queue-land-direct.ts)
|
||||
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
|
||||
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
|
||||
* gstack-ship-queue land --census
|
||||
@@ -57,6 +58,8 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
|
||||
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
|
||||
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
|
||||
and one CHECK: line per workflow; resumable, default timeout 2700 s
|
||||
land --pr <n> --pin <sha> [--dry-run] merge: direct: merge the PR at its own green head (receipt and READY at the pin, no stamp
|
||||
files, checks green, mergeable clean), verify the tree, write a land note
|
||||
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
|
||||
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
|
||||
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
|
||||
@@ -86,7 +89,7 @@ function finish(cmd: string, out: StepOutput): number {
|
||||
}
|
||||
process.stdout.write(formatResult('ship-queue', out.ok ? 'complete' : 'refused', out.run) + '\n');
|
||||
if (out.ok) return EXIT.ok;
|
||||
return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(out.failure.code) ? EXIT.fail : EXIT.refused;
|
||||
return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY', 'QUEUE_LAND_UNVERIFIED'].includes(out.failure.code) ? EXIT.fail : EXIT.refused;
|
||||
}
|
||||
|
||||
const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
@@ -163,6 +166,8 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (cmd === 'train') {
|
||||
const train = await import('../lib/ship-queue-train-cli');
|
||||
if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; }
|
||||
const loaded = args[0] === 'build' || args[0] === 'land' ? loadBasePolicy(ctx) : null;
|
||||
if (loaded && !('message' in loaded) && loaded.policy.merge === 'direct') return finish(`train ${args[0]}`, { ok: false, lines: [], run: 'none', failure: { code: 'QUEUE_NOT_READY', message: 'the base policy sets merge: direct, so trains are off', fix: 'land each READY PR with gstack-ship-queue land --pr <n> --pin <sha> (docs/ship-policy.md#land-direct)' } });
|
||||
return train.trainMain(ctx, args);
|
||||
}
|
||||
if (cmd === 'wave') return (await import('../lib/ship-queue-wave')).waveMain(ctx, args);
|
||||
@@ -171,6 +176,11 @@ async function main(argv: string[]): Promise<number> {
|
||||
const l = await import('../lib/ship-queue-land');
|
||||
if (args.includes('--census')) return finish('land', l.census(ctx));
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr <number> (or --census)');
|
||||
if (args.includes('--pin')) {
|
||||
const pin = flag(args, '--pin') ?? '';
|
||||
if (!/^[0-9a-f]{40,64}$/.test(pin)) return usage('--pin needs the full head sha the READY names');
|
||||
return finish('land', (await import('../lib/ship-queue-land-direct')).landDirect(ctx, { pr, pin, dryRun: args.includes('--dry-run') }));
|
||||
}
|
||||
const timeout = flag(args, '--timeout');
|
||||
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
|
||||
}
|
||||
|
||||
@@ -271,6 +271,33 @@ the same product diff; any other rebase is a full /ship
|
||||
([ship-policy.md](ship-policy.md#receipt-carry)). A controller never asks a lane to
|
||||
rebase a reviewed branch.
|
||||
|
||||
## Direct land (`merge: direct`)
|
||||
|
||||
When the base policy sets `"merge": "direct"`, there are no trains, slot calls or stamp
|
||||
commits. Each PR merges at its own green head, and versions are set at release
|
||||
([ship-policy.md](ship-policy.md#land-direct)):
|
||||
|
||||
1. Lane: /ship, then `gstack-ship-queue ready --pr <n> --no-screen` at the pinned head,
|
||||
then READY to the Captain with that pin. The PR carries a `changelog.d` fragment and
|
||||
leaves VERSION, the `package.json` version and CHANGELOG.md alone.
|
||||
2. Captain: `gstack-ship-queue land --pr <n> --pin <sha>`. It merges with
|
||||
`--match-head-commit`, checks main's tree and writes a land note.
|
||||
3. Release: the release gate stamps the version and folds the fragments.
|
||||
|
||||
What to do with each refusal (nothing was merged unless the code says so):
|
||||
|
||||
| Code | Who acts | Action |
|
||||
|---|---|---|
|
||||
| `QUEUE_LAND_STAMPED` | lane | run the printed `git checkout $(git merge-base origin/main HEAD) -- …`, keep the fragment, commit, /ship and `ready` again |
|
||||
| `QUEUE_LAND_PIN_MOVED` | lane | /ship and `ready` at the new head, new READY |
|
||||
| `QUEUE_RECEIPT_MISSING` / `QUEUE_RECEIPT_STALE` | lane | /ship at the pin |
|
||||
| `QUEUE_NOT_READY` | lane, or Captain | no READY note: `ready --pr <n> --no-screen` at the pin; `merge: train`: use the train; closed or retargeted PR: as named |
|
||||
| `QUEUE_LAND_CHECKS` | Captain, then lane | pending: `gstack-pr-wait <n> --until green` and rerun; red: back to the lane (rule 16 rerun for a flaky case); missing: dispatch the workflow on the branch |
|
||||
| `QUEUE_LAND_NOT_CLEAN` | lane, or Captain | `CONFLICTING`: the lane merges main (rule 19), /ship, `ready`; `UNKNOWN`: rerun in a minute |
|
||||
| `QUEUE_TRAIN_FROZEN` / `QUEUE_LAND_LOCKED` | Captain | finish the repair or the holder first |
|
||||
| `QUEUE_TRAIN_UNTRUSTED` | Captain | a fork head; rewrite on a crew branch |
|
||||
| `QUEUE_LAND_UNVERIFIED` | Captain | merged, but main isn't the pin on its parent: revert the stray change, report it |
|
||||
|
||||
## Merge trains
|
||||
|
||||
gstack's queue tool drives trains when the repository's `.gstack/ship-policy.json` carries
|
||||
|
||||
@@ -63,6 +63,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, land? }`, `{ gates, land, max_size, fill_hours }` 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. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
|
||||
| `merge` | `train` \| `direct` | `train` | `direct` lands each READY PR at its own green head with [`land --pr <n> --pin <sha>`](#land-direct): no train and no stamp commit, and `train build` refuses. A PR then leaves VERSION, the `package.json` version and the CHANGELOG at its merge base, and the release sets them. Needs `changelog_fragments`, and the stamping `land --pr <n>` refuses under it. `train` keeps merge trains. Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. 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`. |
|
||||
|
||||
@@ -1897,6 +1898,42 @@ command:
|
||||
`--dry-run` merges and stamps in a scratch worktree and prints the version and
|
||||
tree, taking no lock and pushing nothing.
|
||||
|
||||
<a id="land-direct"></a>
|
||||
### `land --pin`: merge at the PR's own green head (`merge: direct`)
|
||||
|
||||
Under `"merge": "direct"` in the base policy, the Captain merges one READY PR
|
||||
at a time with `gstack-ship-queue land --pr <n> --pin <sha>`, where `<sha>`
|
||||
is the head the READY names. There's no train, no stamp commit, no base merge
|
||||
and no extra CI round. GitHub's required checks plus `--match-head-commit`
|
||||
stay the gate. Versions aren't set per PR: a PR carries only its
|
||||
`changelog.d` fragment, VERSION stays where it is, and the release stamps the
|
||||
version and folds the fragments. Land refuses, and merges nothing, unless all
|
||||
of these hold:
|
||||
|
||||
| Check | Refusal |
|
||||
|---|---|
|
||||
| the base policy sets `merge: direct` | `QUEUE_NOT_READY` |
|
||||
| the PR is open, on the base, from this repository | `QUEUE_NOT_READY`, `QUEUE_TRAIN_UNTRUSTED` |
|
||||
| the PR head is the pin | [`QUEUE_LAND_PIN_MOVED`](troubleshooting.md#queue-land-pin-moved) |
|
||||
| the PR body's /ship receipt is bound to the pin | `QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE` |
|
||||
| a READY note from `ready` exists at the pin | `QUEUE_NOT_READY` |
|
||||
| no freeze is active and no land lock is held | `QUEUE_TRAIN_FROZEN`, `QUEUE_LAND_LOCKED` |
|
||||
| VERSION, the `package.json` version, the CHANGELOG and `.gstack/version-path` are unchanged since the merge base | [`QUEUE_LAND_STAMPED`](troubleshooting.md#queue-land-stamped) |
|
||||
| every check at the pin is green with none pending, and each `changelog_fragments.required_checks` entry passed there | [`QUEUE_LAND_CHECKS`](troubleshooting.md#queue-land-checks) |
|
||||
| GitHub reports the PR `MERGEABLE` and `CLEAN` | [`QUEUE_LAND_NOT_CLEAN`](troubleshooting.md#queue-land-not-clean) |
|
||||
|
||||
Release outputs (the agents digest) are checked by the Version Gate, which must
|
||||
be green at the pin: it accepts an output only when it equals what the release
|
||||
tool generates at the merge base's VERSION. If the base moved and GitHub still
|
||||
reports the PR clean, land merges it as it is. Then it runs `gh pr merge
|
||||
--squash --match-head-commit <pin>` (`--merge` under `history: merge-only`),
|
||||
reads the merge commit, and checks that its tree is the pin merged onto the
|
||||
merge commit's parent ([`QUEUE_LAND_UNVERIFIED`](troubleshooting.md#queue-land-unverified)
|
||||
if not). Last, it writes a `land` note on `refs/notes/gstack-trains`. A rerun
|
||||
after GitHub merged goes straight to the tree check. `--dry-run` runs every
|
||||
check and merges nothing. Under `merge: direct`, `train build` refuses and
|
||||
names this command.
|
||||
|
||||
<a id="migrating-to-fragments"></a>
|
||||
### Migrating to fragments
|
||||
|
||||
|
||||
@@ -5073,6 +5073,59 @@ 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-land-stamped"></a>
|
||||
### `QUEUE_LAND_STAMPED`
|
||||
|
||||
**Meaning.** Under `merge: direct`, the PR changes VERSION, the
|
||||
`package.json` version or the CHANGELOG. Versions and the CHANGELOG are set at
|
||||
release, so a PR carries only its `changelog.d` fragment. Nothing was merged.
|
||||
|
||||
**Fix.** The lane runs the command the refusal prints, for example
|
||||
`git checkout $(git merge-base origin/main HEAD) -- VERSION CHANGELOG.md`
|
||||
(and sets the `package.json` version back by hand when it's named), keeps or
|
||||
adds the fragment with the entry text, commits, then runs /ship and
|
||||
`gstack-ship-queue ready --pr <n> --no-screen` at the new head.
|
||||
|
||||
<a id="queue-land-pin-moved"></a>
|
||||
### `QUEUE_LAND_PIN_MOVED`
|
||||
|
||||
**Meaning.** The PR head isn't the pin `land --pin` was given, so the READY
|
||||
doesn't describe what would merge. Nothing was merged.
|
||||
|
||||
**Fix.** The lane runs /ship and `ready --pr <n> --no-screen` at the new head
|
||||
and sends a new READY; land that pin.
|
||||
|
||||
<a id="queue-land-checks"></a>
|
||||
### `QUEUE_LAND_CHECKS`
|
||||
|
||||
**Meaning.** A check at the pin is red or pending, or a required check never
|
||||
ran there. Nothing was merged.
|
||||
|
||||
**Fix.** Pending: `gstack-pr-wait <n> --until green`, then rerun land. Red:
|
||||
the lane fixes it at a new head (a flaky case gets its one rerun under rule
|
||||
16). Missing: dispatch that workflow on the branch, then rerun land.
|
||||
|
||||
<a id="queue-land-not-clean"></a>
|
||||
### `QUEUE_LAND_NOT_CLEAN`
|
||||
|
||||
**Meaning.** GitHub doesn't report the PR `MERGEABLE` and `CLEAN`. Nothing
|
||||
was merged.
|
||||
|
||||
**Fix.** `CONFLICTING`: the lane merges the base into its branch, resolves,
|
||||
runs /ship and `ready` at the new head. `UNKNOWN`: GitHub is still
|
||||
computing, so rerun land in a minute. `BLOCKED` or `BEHIND`: meet the branch
|
||||
rule it names.
|
||||
|
||||
<a id="queue-land-unverified"></a>
|
||||
### `QUEUE_LAND_UNVERIFIED`
|
||||
|
||||
**Meaning.** GitHub merged the PR, but the base branch's merge commit tree
|
||||
isn't the pin merged onto that commit's parent, so something besides the
|
||||
reviewed pin reached main. No land note was written.
|
||||
|
||||
**Fix.** Compare the merge commit with `git merge-tree --write-tree <parent>
|
||||
<pin>`, revert what doesn't belong to the PR, and report it to the Captain.
|
||||
|
||||
<a id="queue-train-stamp-mismatch"></a>
|
||||
### `QUEUE_TRAIN_STAMP_MISMATCH`
|
||||
|
||||
|
||||
@@ -472,6 +472,32 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'the base branch moved during the stamp commit\'s CI on each of the allowed restamp attempts, so land stopped instead of restamping again',
|
||||
fix: 'find what is merging outside land (every merge should go through it), then rerun gstack-ship-queue land --pr <n>; it resumes from its journal',
|
||||
},
|
||||
// gstack-ship-queue land --pin (lib/ship-queue-land-direct.ts): merge: direct refusals and its post-merge check.
|
||||
QUEUE_LAND_STAMPED: {
|
||||
anchor: 'queue-land-stamped',
|
||||
summary: 'under merge: direct the PR changes VERSION, the package.json version or the CHANGELOG; versions and the CHANGELOG are set at release, so a PR carries only its changelog fragment',
|
||||
fix: 'put the named files back to the merge base (git checkout $(git merge-base origin/main HEAD) -- <files>), keep or add the changelog.d fragment, commit, then /ship and ready at the new head',
|
||||
},
|
||||
QUEUE_LAND_PIN_MOVED: {
|
||||
anchor: 'queue-land-pin-moved',
|
||||
summary: 'the PR head is not the pin land was given, so the READY it was given does not describe what would merge',
|
||||
fix: 'the lane runs /ship and gstack-ship-queue ready --pr <n> --no-screen at the new head and reports READY with it; land that pin',
|
||||
},
|
||||
QUEUE_LAND_CHECKS: {
|
||||
anchor: 'queue-land-checks',
|
||||
summary: 'a check at the pin is red or pending, or a required check never ran there',
|
||||
fix: 'wait for pending checks (gstack-pr-wait <n> --until green), fix a red one at a new head, or dispatch a missing required workflow on the branch; then rerun land',
|
||||
},
|
||||
QUEUE_LAND_NOT_CLEAN: {
|
||||
anchor: 'queue-land-not-clean',
|
||||
summary: 'GitHub does not report the PR mergeable and clean (a conflict, a branch rule, or a state GitHub is still computing)',
|
||||
fix: 'CONFLICTING: the lane merges the base into the branch, resolves, runs /ship and ready at the new head; UNKNOWN: rerun land in a minute; BLOCKED or BEHIND: meet the branch rule it names',
|
||||
},
|
||||
QUEUE_LAND_UNVERIFIED: {
|
||||
anchor: 'queue-land-unverified',
|
||||
summary: 'after the merge, the base branch tree is not the pin merged onto the merge commit\'s parent, so something besides the reviewed pin reached main',
|
||||
fix: 'compare the merge commit with the pin merged onto its parent, revert what does not belong to the PR, and report it to the Captain',
|
||||
},
|
||||
// gstack-pr-wait (lib/pr-wait.ts): the landing-path PR watcher's two terminal codes.
|
||||
PR_WAIT_BLOCKED: {
|
||||
anchor: 'pr-wait-blocked',
|
||||
|
||||
+6
-2
@@ -86,6 +86,7 @@ export interface ShipPolicy {
|
||||
docs: { caps: Array<{ path: string; max_bytes: number }> } | null;
|
||||
/** How /ship reproduces a Step 5 failure on the base branch before calling it base-red (docs/ship-policy.md#base-red); null never calls one base-red. */
|
||||
base_red: BaseRedPolicy | null;
|
||||
/** `direct` lands each READY PR at its own green head (`land --pin`, docs/ship-policy.md#land-direct); `train` keeps merge trains. */ merge: 'direct' | 'train';
|
||||
}
|
||||
|
||||
/** `base_red`: `setup` installs dependencies in a scratch checkout (null when none are needed); `select` runs the failing test files, named by its `{files}` slot. */
|
||||
@@ -176,7 +177,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
|
||||
min_gstack_version: null,
|
||||
train: null,
|
||||
docs: null,
|
||||
base_red: null,
|
||||
base_red: null, merge: 'train',
|
||||
};
|
||||
|
||||
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
|
||||
@@ -210,6 +211,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||||
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? } (the gstack-evals gate) or { gates, land, max_size, fill_hours } (any repo: gates lists { kind: workflows, workflows, required_jobs, record?, post_merge_checks? } and { kind: command, command, timeout_sec, spend_cap_usd, pass_env? }, each GREEN on every prefix; land is { merge_method: squash|merge|rebase, required_checks? }) or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|land|watch|init|doctor|prune, ready --screen|--no-screen, verify --train). gates and land need min_gstack_version at the release that reads them (validate names it). null keeps today\'s per-PR queue unchanged.',
|
||||
docs: '{ caps: [{ path, max_bytes }] } or null: documentation files with a byte cap. /ship Step 14.5 runs `gstack-ship-receipt docs-caps` before the verification gate and prints one DOCS_CAP: line per cap; a file over its cap (or absent) blocks the ship until the doc is trimmed or the cap raised on the base branch. null declares none.',
|
||||
base_red: '{ setup, select, timeout_sec } or null: how /ship Step 5 proves a failure is the base branch\'s own (docs/ship-policy.md#base-red). gstack-base-red checks out the base head and the PR head in scratch worktrees, runs setup (dependency install, or null), then select with the failing test files in its {files} slot; only the same failures in both, in files the PR does not touch, are base-red, and /ship then pauses before READY instead of failing. Read from the base branch only. null keeps every failure in-branch.',
|
||||
merge: 'train (default) | direct. direct: the Captain lands each READY PR at its own green head with `gstack-ship-queue land --pr <n> --pin <sha>` (no train, no stamp commit; the PR leaves VERSION, the manifest version and the CHANGELOG at the base and the release stamps them), and train build refuses. train: merge trains as before (docs/ship-policy.md#land-direct).',
|
||||
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
|
||||
};
|
||||
|
||||
@@ -249,6 +251,7 @@ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0';
|
||||
export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0';
|
||||
export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0';
|
||||
export const REVIEW_POLICY_KEY_VERSION = '1.94.30.0';
|
||||
export const MERGE_KEY_VERSION = '1.95.0.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
@@ -257,7 +260,7 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
|
||||
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
|
||||
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION,
|
||||
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION,
|
||||
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, merge: MERGE_KEY_VERSION,
|
||||
};
|
||||
|
||||
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
|
||||
@@ -481,6 +484,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
|
||||
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
|
||||
oneOf('history', ['merge-only', 'squash-ok']);
|
||||
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
|
||||
oneOf('merge', ['direct', 'train']);
|
||||
if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors);
|
||||
if ('review' in obj) reviewErrors(obj.review, errors, (k, v) => listErrors(k, v, errors, globError));
|
||||
if ('release_tool' in obj && obj.release_tool !== null) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* ship-queue-land-direct — B1's merge step, behind `gstack-ship-queue land --pr <n> --pin <sha>` under the base
|
||||
* policy's merge: direct (docs/ship-policy.md#land-direct). The PR merges at its own green head: no train, no stamp
|
||||
* commit, no base merge and no extra CI round. It refuses unless the /ship receipt and the READY note are bound to the
|
||||
* pin, the pin is the PR's head, the diff leaves VERSION, the manifest version and the CHANGELOG at its merge base
|
||||
* (versions are set at release), every check at the pin is green with none pending and each required check present,
|
||||
* and GitHub reports the PR mergeable and clean. Then gh pr merge --squash --match-head-commit <pin>, a check that the
|
||||
* merge commit's tree is the pin merged onto that commit's parent, and a land note on refs/notes/gstack-trains. A rerun
|
||||
* after GitHub merged goes straight to the tree check.
|
||||
*/
|
||||
import { versionAtRef } from './restamp';
|
||||
import type { ShipPolicy } from './ship-policy';
|
||||
import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue';
|
||||
import { git, type QueueFailure } from './ship-queue-gate';
|
||||
import { lockLabel, readLock } from './ship-queue-land-lock';
|
||||
import { activeFreeze, loadReady, persist, rebuildFromNotes, repoIdentity } from './ship-queue-train-record';
|
||||
import { bindShippedReceipt } from './ship-receipt-depth';
|
||||
|
||||
interface DirectPr { state: string; baseRefName?: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; mergeable?: string; mergeStateStatus?: string; mergeCommit?: { oid: string } | null }
|
||||
export interface DirectOptions { pr: string; pin: string; dryRun?: boolean; now?: () => Date }
|
||||
|
||||
const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? '');
|
||||
|
||||
function view(ctx: QueueContext, pr: string): DirectPr | QueueFailure {
|
||||
const r = ctx.gh(['pr', 'view', pr, '--json', 'state,baseRefName,headRefName,headRefOid,isCrossRepository,body,mergeable,mergeStateStatus,mergeCommit']);
|
||||
try { return JSON.parse(r.out) as DirectPr; } catch { return { code: 'QUEUE_NOT_READY', message: `gh pr view ${pr} failed: ${(r.err ?? r.out).trim().slice(0, 160)}` }; }
|
||||
}
|
||||
|
||||
/** Release files the pin changed since its merge base: the version file, the manifest version and the CHANGELOG; an unreadable version is an error, never a pass. */
|
||||
export function stampedFiles(root: string, policy: ShipPolicy, mergeBase: string, pin: string): string[] | { error: string } {
|
||||
const out: string[] = [];
|
||||
const [was, now] = [versionAtRef(root, mergeBase), versionAtRef(root, pin)];
|
||||
if ('error' in was || 'error' in now) return { error: 'error' in was ? was.error : (now as { error: string }).error };
|
||||
if (was.version !== now.version) out.push(now.rel);
|
||||
const npm = (rev: string) => { const r = git(root, ['show', `${rev}:package.json`]); try { return r.ok ? JSON.parse(r.out).version ?? null : null; } catch { return null; } };
|
||||
if (!('rel' in now && now.rel === 'package.json') && npm(mergeBase) !== npm(pin)) out.push('package.json version');
|
||||
if (policy.changelog && git(root, ['diff', '--quiet', mergeBase, pin, '--', policy.changelog]).ok === false) out.push(policy.changelog);
|
||||
if (!git(root, ['diff', '--quiet', mergeBase, pin, '--', '.gstack/version-path']).ok) out.push('.gstack/version-path');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The pin's commit is local, fetched from its branch or GitHub's refs/pull/<n>/head (which outlives a deleted branch). */
|
||||
function havePin(root: string, pr: string, pin: string, branch?: string): boolean {
|
||||
const have = () => git(root, ['cat-file', '-e', `${pin}^{commit}`]).ok;
|
||||
if (have()) return true;
|
||||
if (branch) git(root, ['fetch', '-q', 'origin', `refs/heads/${branch}`]);
|
||||
if (!have()) git(root, ['fetch', '-q', 'origin', `refs/pull/${pr}/head`]);
|
||||
return have();
|
||||
}
|
||||
|
||||
/** The merge commit's tree is the pin merged onto the merge commit's first parent: GitHub merged exactly the pin. A read that fails is "cannot verify", never a mismatch. */
|
||||
function verifyMerge(ctx: QueueContext, root: string, pr: string, pin: string, merge: string, lines: string[], run: string, now: Date): StepOutput {
|
||||
const cannot = (why: string): StepOutput => ({ ok: false, run, lines, failure: { code: 'QUEUE_NOT_READY', message: `#${pr} merged as ${merge.slice(0, 12)}, but land cannot verify it: ${why}`, fix: `fix the read named, then rerun gstack-ship-queue land --pr ${pr} --pin ${pin}; it goes straight to the tree check` } });
|
||||
if (!git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return cannot(`git fetch origin ${ctx.base} failed`);
|
||||
if (!havePin(root, pr, pin)) return cannot(`the pin ${pin.slice(0, 12)} is not in this clone and refs/pull/${pr}/head did not fetch it`);
|
||||
if (!git(root, ['merge-base', '--is-ancestor', merge, `origin/${ctx.base}`]).ok) return { ok: false, run, lines, failure: { code: 'QUEUE_LAND_UNVERIFIED', message: `the merge commit ${merge.slice(0, 12)} is not on origin/${ctx.base}` } };
|
||||
const parent = git(root, ['rev-parse', `${merge}^1`]).out;
|
||||
const tree = git(root, ['rev-parse', `${merge}^{tree}`]).out;
|
||||
const mt = git(root, ['merge-tree', '--write-tree', parent, pin]);
|
||||
const expected = mt.out.split('\n')[0] ?? '';
|
||||
if (!isSha(expected)) return cannot(`git merge-tree ${parent.slice(0, 12)} ${pin.slice(0, 12)} failed: ${mt.err.split('\n')[0]}`);
|
||||
if (!mt.ok || tree !== expected) return { ok: false, run, lines: [...lines, `MAIN: ${merge} tree=${tree} parent=${parent} expected=${expected || 'conflict'}`], failure: { code: 'QUEUE_LAND_UNVERIFIED', message: `origin/${ctx.base} ${merge.slice(0, 12)} is not the pin ${pin.slice(0, 12)} merged onto ${parent.slice(0, 12)}`, fix: `compare git diff ${expected || parent} ${merge} and revert what does not belong to #${pr}; report it to the Captain` } };
|
||||
const keys = repoIdentity(root);
|
||||
const at = now.toISOString();
|
||||
const note = persist(root, [{ schema: 1, kind: 'land', repo_key: keys.key, pr, pin, base_sha: parent, merge_sha: merge, tree, landed_at: at, updated_at: at }], { remote: ctx.remote });
|
||||
return { ok: true, run, lines: [...lines, `MERGED: #${pr} as ${merge}`, `LANDED: origin/${ctx.base} tree ${tree} equals the pin merged onto ${parent}`, `NOTE: land #${pr}@${pin.slice(0, 12)} (${note.detail})`] };
|
||||
}
|
||||
|
||||
/** `gstack-ship-queue land --pr <n> --pin <sha>`: one pass; a refusal merges nothing. */
|
||||
export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput {
|
||||
const root = ctx.repoRoot;
|
||||
const run = 'none';
|
||||
const now = o.now ?? (() => new Date());
|
||||
const fail = (lines: string[], failure: QueueFailure): StepOutput => ({ ok: false, lines, failure, run });
|
||||
if (!git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return fail([], { code: 'QUEUE_NOT_READY', message: `cannot fetch origin/${ctx.base}; land reads the policy and the required checks from the current base`, fix: 'fix the fetch, then rerun land' });
|
||||
const loaded = loadBasePolicy(ctx);
|
||||
if ('message' in loaded) return fail([], loaded);
|
||||
const policy = loaded.policy;
|
||||
if (policy.merge === 'direct' && !policy.changelog_fragments) return fail([], { code: 'QUEUE_NOT_READY', message: 'merge: direct needs changelog_fragments: a PR carries its entry as a fragment, and ready records READY notes only under that key', fix: 'add "changelog_fragments": { "dir": "changelog.d" } to .gstack/ship-policy.json on the base' });
|
||||
if (policy.merge !== 'direct') return fail([], { code: 'QUEUE_NOT_READY', message: `the base policy sets merge: ${policy.merge}; land --pin merges only under merge: direct`, fix: 'land through the train (gstack-ship-queue train build), or set "merge": "direct" in .gstack/ship-policy.json on the base' });
|
||||
const info = view(ctx, o.pr);
|
||||
if ('code' in info) return fail([], info);
|
||||
const lines = [`LAND: #${o.pr} pin=${o.pin} direct`];
|
||||
if (info.isCrossRepository) return fail(lines, { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land merges only crew branches` });
|
||||
if (info.baseRefName && info.baseRefName !== ctx.base) return fail(lines, { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}`, fix: `retarget it to ${ctx.base}, or land its parent first` });
|
||||
if (info.state === 'MERGED' && info.headRefOid === o.pin && isSha(info.mergeCommit?.oid)) {
|
||||
lines.push(`#${o.pr} is already merged as ${info.mergeCommit!.oid.slice(0, 12)}; checking the tree`);
|
||||
return verifyMerge(ctx, root, o.pr, o.pin, info.mergeCommit!.oid, lines, run, now());
|
||||
}
|
||||
if (info.state !== 'OPEN') return fail(lines, { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` });
|
||||
if (info.headRefOid !== o.pin) return fail(lines, { code: 'QUEUE_LAND_PIN_MOVED', message: `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not the pin ${o.pin.slice(0, 12)}`, fix: `the lane runs /ship and gstack-ship-queue ready --pr ${o.pr} at the new head and reports READY with it` });
|
||||
|
||||
if (!havePin(root, o.pr, o.pin, info.headRefName)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot fetch the pin ${o.pin.slice(0, 12)} from ${info.headRefName} or refs/pull/${o.pr}/head`, fix: 'fix the fetch, then rerun land' });
|
||||
const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [o.pin]);
|
||||
lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : []));
|
||||
if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: `run /ship at the pin, then gstack-ship-queue ready --pr ${o.pr}` });
|
||||
const keys = repoIdentity(root).keys;
|
||||
rebuildFromNotes(root, ctx.remote ?? 'origin');
|
||||
if (!loadReady(keys, o.pr, o.pin)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `no READY note for #${o.pr} at the pin ${o.pin.slice(0, 12)}`, fix: `the lane runs gstack-ship-queue ready --pr ${o.pr} --no-screen at the pin` });
|
||||
const freeze = activeFreeze(keys);
|
||||
if (freeze) return fail(lines, { code: 'QUEUE_TRAIN_FROZEN', message: `main is frozen by train ${freeze.train} (${freeze.reason})` });
|
||||
const lock = readLock(root, ctx.remote ?? 'origin');
|
||||
if (lock.state === 'held') return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `${lockLabel(lock.lock)} holds the land lock; a direct land would race it onto main` });
|
||||
|
||||
const mergeBase = git(root, ['merge-base', `origin/${ctx.base}`, o.pin]).out;
|
||||
if (!isSha(mergeBase)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot compute the merge base of the pin ${o.pin.slice(0, 12)} and origin/${ctx.base}, so the release-file check cannot run`, fix: `git fetch origin ${ctx.base} and the PR head (an unshallow clone), then rerun land` });
|
||||
const stamped = stampedFiles(root, policy, mergeBase, o.pin);
|
||||
if ('error' in stamped) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot read the version at the pin or its merge base: ${stamped.error}` });
|
||||
if (stamped.length) {
|
||||
const files = stamped.filter(f => f !== 'package.json version');
|
||||
const restore = files.length ? `git checkout $(git merge-base origin/${ctx.base} HEAD) -- ${files.join(' ')}` : '';
|
||||
const npm = stamped.includes('package.json version') ? 'set package.json "version" back to its merge base value' : '';
|
||||
return fail(lines, { code: 'QUEUE_LAND_STAMPED', message: `#${o.pr} changes ${stamped.join(', ')}; under merge: direct the release sets versions and the CHANGELOG, a PR carries only its changelog fragment`, fix: `${[restore, npm].filter(Boolean).join(' and ')}, keep (or add) the changelog.d fragment, commit, then /ship and ready at the new head` });
|
||||
}
|
||||
|
||||
const ci = ciAtHead(ctx, o.pin);
|
||||
lines.push(`CI: ${ci.line}`);
|
||||
if (!ci.green) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `checks are ${ci.line}`, fix: `wait for pending checks (gstack-pr-wait ${o.pr} --until green), or fix the red one at a new head; then rerun land` });
|
||||
const passed = new Set([...ci.checks.filter(c => c.conclusion === 'success').map(c => c.name), ...ci.contexts.filter(c => c.state === 'success').map(c => c.name)]);
|
||||
const missing = (policy.changelog_fragments?.required_checks ?? []).filter(n => !passed.has(n));
|
||||
if (missing.length) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `required check(s) ${missing.join(', ')} did not pass at the pin ${o.pin.slice(0, 12)}`, fix: `dispatch them on the branch (gh workflow run <workflow> --ref ${info.headRefName}) and rerun land once they pass` });
|
||||
if (info.mergeable !== 'MERGEABLE' || info.mergeStateStatus !== 'CLEAN') return fail(lines, { code: 'QUEUE_LAND_NOT_CLEAN', message: `GitHub reports #${o.pr} mergeable=${info.mergeable ?? 'unknown'} mergeStateStatus=${info.mergeStateStatus ?? 'unknown'}`, fix: info.mergeable === 'CONFLICTING' ? `the lane merges origin/${ctx.base} into the branch, resolves, runs /ship and ready at the new head` : 'UNKNOWN means GitHub is still computing: rerun land in a minute; BLOCKED or BEHIND names the branch rule to meet first' });
|
||||
if (o.dryRun) return { ok: true, run, lines: [...lines, `DRY-RUN: nothing merged (would run gh pr merge ${o.pr} --squash --match-head-commit ${o.pin})`] };
|
||||
|
||||
const method = policy.history === 'merge-only' ? '--merge' : '--squash';
|
||||
const m = ctx.gh(['pr', 'merge', o.pr, method, '--match-head-commit', o.pin]);
|
||||
let merge: string | undefined;
|
||||
for (let i = 0; i < (m.ok ? 4 : 1) && !isSha(merge); i++) {
|
||||
if (i) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3_000);
|
||||
const after = view(ctx, o.pr);
|
||||
merge = 'code' in after || after.state !== 'MERGED' ? undefined : after.mergeCommit?.oid;
|
||||
}
|
||||
if (!isSha(merge) && m.ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge accepted #${o.pr}, but GitHub does not show it merged yet; nothing is verified`, fix: `rerun gstack-ship-queue land --pr ${o.pr} --pin ${o.pin} in a minute; once GitHub shows the merge it goes straight to the tree check` });
|
||||
if (!isSha(merge)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200) || 'the PR is not merged'}`, fix: `read the refusal; rerun land --pr ${o.pr} --pin ${o.pin} once it is fixed` });
|
||||
return verifyMerge(ctx, root, o.pr, o.pin, merge!, lines, run, now());
|
||||
}
|
||||
@@ -325,6 +325,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
|
||||
if ('message' in loaded) return fail([], loaded, run);
|
||||
const policy = loaded.policy;
|
||||
if (o.repair) return repair(ctx, policy, readJson<LandJournal>(file), file, run);
|
||||
if (policy.merge === 'direct') return fail([], { code: 'QUEUE_NOT_READY', message: 'the base policy sets merge: direct, where land never stamps', fix: `gstack-ship-queue land --pr ${o.pr} --pin <the READY's head sha>` }, run);
|
||||
const info = prInfo(ctx, o.pr);
|
||||
if ('code' in info) return fail([], info, run);
|
||||
if (info.isCrossRepository) return fail([], { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land stamps and pushes only crew branches` }, run);
|
||||
|
||||
@@ -117,7 +117,9 @@ export interface ReadyReceipt {
|
||||
schema: 1; kind: 'ready'; repo_key: string; pr: string; head: string; base_sha: string; ready_at: string; screen: ScreenChoice; updated_at: string;
|
||||
base_red?: BaseRedCondition; fixes_base_red?: string[];
|
||||
}
|
||||
export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt | WaveRecord;
|
||||
/** What `land --pin` merged (docs/ship-policy.md#land-direct): the pin, the base it landed on and the verified merge commit. */
|
||||
export interface LandNote { schema: 1; kind: 'land'; repo_key: string; pr: string; pin: string; base_sha: string; merge_sha: string; tree: string; landed_at: string; updated_at: string }
|
||||
export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt | WaveRecord | LandNote;
|
||||
|
||||
/** Clock seam for fill-time and record tests (`GSTACK_SHIP_QUEUE_NOW`, ISO-8601). */
|
||||
export function nowIso(): string {
|
||||
@@ -141,6 +143,7 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
|
||||
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
|
||||
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
|
||||
export const wavePath = (nonce: string) => path.join(queueRoot(), 'waves', `${nonce}.json`);
|
||||
export const landPath = (repoKey: string, pr: string, pin: string) => path.join(queueRoot(), 'landed', repoKey, `${pr}-${pin}.json`);
|
||||
|
||||
/** Hosts that serve GitHub repositories at /<owner>/<repo> (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */
|
||||
const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai'];
|
||||
@@ -226,6 +229,7 @@ export function recordPath(r: AnyRecord): string {
|
||||
if (r.kind === 'train-bisect') return bisectPath(r.train);
|
||||
if (r.kind === 'freeze') return freezePath(r.repo_key);
|
||||
if (r.kind === 'wave') return wavePath(r.nonce);
|
||||
if (r.kind === 'land') return landPath(r.repo_key, r.pr, r.pin);
|
||||
return readyPath(r.repo_key, r.pr, r.head);
|
||||
}
|
||||
|
||||
@@ -235,6 +239,7 @@ export function noteName(r: AnyRecord): string {
|
||||
if (r.kind === 'train-bisect') return `gstack-trains:bisect:${r.train}`;
|
||||
if (r.kind === 'freeze') return `gstack-trains:freeze:${r.repo_key}`;
|
||||
if (r.kind === 'wave') return `gstack-trains:wave:${r.nonce}`;
|
||||
if (r.kind === 'land') return `gstack-trains:land:${r.repo_key}:${r.pr}:${r.pin}`;
|
||||
return `gstack-trains:ready:${r.repo_key}:${r.pr}:${r.head}`;
|
||||
}
|
||||
|
||||
@@ -299,6 +304,7 @@ function parseNote(text: string): AnyRecord | null {
|
||||
if ((r.kind === 'train-attempt' && ids.test(r.train) && ids.test(r.id)) || (r.kind === 'train-bisect' && ids.test(r.train))) return r;
|
||||
if (r.kind === 'freeze' && ids.test(r.repo_key)) return r;
|
||||
if (r.kind === 'wave' && ids.test(r.nonce) && ids.test(r.repo_key) && Array.isArray(r.constituents)) return r;
|
||||
if (r.kind === 'land' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.pin)) return r;
|
||||
if (r.kind === 'ready' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.head)) return r;
|
||||
} catch {}
|
||||
return null;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.94.44",
|
||||
"version": "1.95.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -51,6 +51,7 @@ const WINDOWS_FRAGILE_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
|
||||
// when possible; this list is for environment-/runtime-specific tests where
|
||||
// the failure mode is structural rather than detectable via source-file scan.
|
||||
export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [
|
||||
{ file: 'test/ship-queue-land-direct.test.ts', reason: 'the same extensionless node gh shim as test/ship-queue-land.test.ts; windows-latest launches the runner\'s gh.exe instead (no GH_TOKEN), so every land case fails (windows-scoped-shard, run 41720760327); land is a controller command run on Linux and macOS' },
|
||||
{ file: 'test/ship-queue-land.test.ts', reason: 'its gh shim is an extensionless #!/usr/bin/env node script on a colon-joined PATH, which windows-latest cannot launch, so land reaches the real gh (no GH_TOKEN) and every case fails (windows-free-shard 6, run 40824965988); land is a controller command run on Linux and macOS' },
|
||||
// Like test/paid-rerun.test.ts (excluded by pattern), it replays a gate capture through the paid lane's --report and --rerun-plan CLI.
|
||||
{ file: 'test/shared-libs-index-flags-readonly.test.ts', reason: 'its assume-unchanged replay seeds the review through test/helpers/shared-libs-path-fixture.ts, which launches bin/gstack-review-log (a bash script) directly; windows-latest refuses it as not found in $PATH (windows-free-shard 5, run 40727825859)' },
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* gstack-ship-queue land --pr <n> --pin <sha> (docs/ship-policy.md#land-direct): B1's direct land over real fixture
|
||||
* repos (bare origin, lane clone) and a gh shim that answers pr view (with mergeable, mergeStateStatus and the merge
|
||||
* commit), check runs and a squash merge against the bare origin. Covers the happy path for an unstamped,
|
||||
* fragment-only head, a base that moved but still merges clean, a refusal for each condition, the post-merge tree
|
||||
* check, the land note, train build under merge: direct, and the policy key's validation.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { MERGE_KEY_VERSION, validatePolicy } from '../lib/ship-policy';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
import { shippedBody } from './helpers/ship-queue-train-fixture';
|
||||
import { persist, repoIdentity } from '../lib/ship-queue-train-record';
|
||||
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
|
||||
import { bumpVersion } from '../lib/version-gate-train';
|
||||
|
||||
const REQUIRED = ['free-tests', 'Check VERSION is not stale vs queue'];
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d', required_checks: REQUIRED }, merge: 'direct', min_gstack_version: MERGE_KEY_VERSION };
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
interface Fx { repo: FixtureRepo; env: Record<string, string>; write: (fn: (d: any) => void) => void; read: () => any }
|
||||
function setup(policy: Record<string, unknown> = POLICY): Fx {
|
||||
const repo = makeFixtureRepo({ policy, prefix: 'gstack-land-direct-fx-' });
|
||||
repos.push(repo);
|
||||
const dir = path.join(repo.root, 'gh-shim');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const data = path.join(dir, 'data.json');
|
||||
fs.writeFileSync(data, JSON.stringify({ prs: {}, checks: {}, defaultChecks: REQUIRED }));
|
||||
fs.writeFileSync(path.join(dir, 'gh'), [
|
||||
'#!/usr/bin/env node',
|
||||
"const fs=require('fs');const {execFileSync}=require('child_process');",
|
||||
'const D=process.env.LAND_SHIM_DATA, O=process.env.LAND_SHIM_ORIGIN;',
|
||||
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const a=process.argv.slice(2);",
|
||||
"const g=(...x)=>execFileSync('git',['--git-dir',O,...x],{encoding:'utf8',timeout:30000}).trim();",
|
||||
"const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
|
||||
"if(a[0]==='pr'&&a[1]==='view'){const p=d.prs[a[2]];if(!p){console.error('no pr');process.exit(1)}",
|
||||
" let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};",
|
||||
" console.log(JSON.stringify({mergeable:'MERGEABLE',mergeStateStatus:'CLEAN',...p,headRefOid:p.headRefOid??head,mergeCommit:p.mergeSha?{oid:p.mergeSha}:null}));process.exit(0)}",
|
||||
"if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);",
|
||||
" if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}",
|
||||
" const set=d.checks[m[1]]??Object.fromEntries(d.defaultChecks.map(n=>[n,'success']));",
|
||||
" const runs=Object.entries(set).map(([name,c],i)=>({id:i+1,name,status:c==='pending'?'in_progress':'completed',conclusion:c==='pending'?null:c,completed_at:'2026-11-03T00:00:00Z',app:{id:1}}));",
|
||||
" console.log(JSON.stringify({total_count:runs.length,check_runs:runs}));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];const head=g('rev-parse','refs/heads/'+p.headRefName);",
|
||||
" if(head!==want){console.error('Head branch was modified');process.exit(1)}",
|
||||
" const main=g('rev-parse','refs/heads/main');let tree=g('merge-tree','--write-tree',main,want).split('\\n')[0];",
|
||||
" if(d.strayOnMerge){tree=g('rev-parse',main+'^{tree}')}",
|
||||
" const sq=g('commit-tree',tree,'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);",
|
||||
" d.mergeArgs=a.slice(2);p.state='MERGED';p.mergeSha=sq;save();process.exit(0)}",
|
||||
"console.error('gh shim: unsupported '+a.join(' '));process.exit(1);",
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const env = { PATH: dir + ':' + (process.env.PATH ?? ''), LAND_SHIM_DATA: data, LAND_SHIM_ORIGIN: repo.origin };
|
||||
const read = () => JSON.parse(fs.readFileSync(data, 'utf8'));
|
||||
return { repo, env, read, write: fn => { const d = read(); fn(d); fs.writeFileSync(data, JSON.stringify(d)); } };
|
||||
}
|
||||
|
||||
/** The READY receipt gstack-ship-queue ready records (a note on origin refs/notes/gstack-trains), written from another state root. */
|
||||
function markReady(fx: Fx, pr: string, head: string): void {
|
||||
const prior = process.env.GSTACK_STATE_ROOT;
|
||||
process.env.GSTACK_STATE_ROOT = path.join(fx.repo.root, 'ready-state');
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
persist(fx.repo.work, [{ schema: 1, kind: 'ready', repo_key: repoIdentity(fx.repo.work).key, pr, head, base_sha: fx.repo.git('rev-parse', 'origin/main'), ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now }]);
|
||||
} finally { if (prior === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prior; }
|
||||
}
|
||||
|
||||
/** A PR branch from origin/main with one product file and a fragment, pushed, shipped (receipt at its head) and READY. */
|
||||
function openPr(fx: Fx, pr: string, opts: { edit?: (work: string) => void; shipped?: boolean; ready?: boolean; fork?: boolean } = {}): string {
|
||||
const { repo } = fx;
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', 'origin/main', '-B', 'pr-' + pr);
|
||||
fs.writeFileSync(path.join(repo.work, 'pr-' + pr + '.txt'), pr + '\n');
|
||||
fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'changelog.d', 'pr-' + pr + '.md'), '- #' + pr + ' change\n');
|
||||
opts.edit?.(repo.work);
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'pr ' + pr);
|
||||
repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-' + pr);
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
fx.write(d => { d.prs[pr] = { state: 'OPEN', baseRefName: 'main', headRefName: 'pr-' + pr, isCrossRepository: !!opts.fork, title: 'PR ' + pr, body: opts.shipped === false ? '' : shippedBody(pr, head) }; });
|
||||
if (opts.ready !== false) markReady(fx, pr, head);
|
||||
repo.git('checkout', '-q', '--detach', 'origin/main');
|
||||
return head;
|
||||
}
|
||||
const land = (fx: Fx, pr: string, pin: string, extra: string[] = []) => runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', pr, '--pin', pin, '--base', 'main', ...extra], fx.env);
|
||||
const remote = (fx: Fx, ref: string) => { fx.repo.git('fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); return fx.repo.git('rev-parse', 'origin/' + ref); };
|
||||
const show = (fx: Fx, rev: string) => fx.repo.git('show', rev);
|
||||
const landNotes = (fx: Fx) => {
|
||||
const notes = (...a: string[]) => fx.repo.git('--git-dir', fx.repo.origin, 'notes', '--ref=refs/notes/gstack-trains', ...a);
|
||||
let list = '';
|
||||
try { list = notes('list'); } catch { return []; }
|
||||
return list.split('\n').filter(Boolean).map(l => JSON.parse(notes('show', l.split(' ')[1]!))).filter(r => r.kind === 'land');
|
||||
};
|
||||
/** Whether a clone holds a commit. */
|
||||
const spawnCheck = (dir: string, sha: string) => spawnSync('git', ['-C', dir, 'cat-file', '-e', `${sha}^{commit}`], { timeout: 30_000 }).status === 0;
|
||||
/** Commit a file straight onto origin/main (another PR that landed first). */
|
||||
function moveMain(fx: Fx, file: string, text: string): string {
|
||||
const { repo } = fx;
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', '--detach', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, file), text);
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'other change on main');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
describe('land --pin: direct merge at the PR\'s own green head', () => {
|
||||
test('happy path: an unstamped, fragment-only head merges with --squash --match-head-commit, main keeps its VERSION, and a land note is written', () => {
|
||||
const fx = setup();
|
||||
const base = remote(fx, 'main');
|
||||
const pin = openPr(fx, '7');
|
||||
const r = land(fx, '7', pin);
|
||||
expect(r.err).toBe('');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin]);
|
||||
const main = remote(fx, 'main');
|
||||
expect(fx.repo.git('rev-parse', main + '^1')).toBe(base);
|
||||
expect(r.out).toContain('MERGED: #7 as ' + main);
|
||||
expect(r.out).toMatch(/^LANDED: origin\/main tree [0-9a-f]+ equals the pin merged onto [0-9a-f]+$/m);
|
||||
expect(show(fx, main + ':VERSION')).toBe(show(fx, base + ':VERSION'));
|
||||
expect(show(fx, main + ':changelog.d/pr-7.md')).toBe('- #7 change');
|
||||
const notes = landNotes(fx);
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toMatchObject({ kind: 'land', pr: '7', pin, merge_sha: main, base_sha: base });
|
||||
}, 90_000);
|
||||
|
||||
test('the base moved and the PR still merges clean: it lands as it is, on the new base, with no restamp or extra CI round', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '8');
|
||||
const moved = moveMain(fx, 'other.txt', 'other\n');
|
||||
const r = land(fx, '8', pin);
|
||||
expect(r.err).toBe('');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
const main = remote(fx, 'main');
|
||||
expect(fx.repo.git('rev-parse', main + '^1')).toBe(moved);
|
||||
expect(show(fx, main + ':other.txt')).toBe('other');
|
||||
expect(show(fx, main + ':pr-8.txt')).toBe('8');
|
||||
expect(fx.repo.git('rev-parse', 'origin/pr-8')).toBe(pin);
|
||||
}, 90_000);
|
||||
|
||||
test('a rerun after GitHub merged the PR goes straight to the tree check and lands', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '9');
|
||||
expect(land(fx, '9', pin).status).toBe(EXIT.ok);
|
||||
const again = land(fx, '9', pin);
|
||||
expect(again.err).toBe('');
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(again.out).toContain('already merged');
|
||||
expect(again.out).toMatch(/^LANDED: /m);
|
||||
}, 90_000);
|
||||
|
||||
test('--dry-run checks everything and merges nothing', () => {
|
||||
const fx = setup();
|
||||
const base = remote(fx, 'main');
|
||||
const pin = openPr(fx, '10');
|
||||
const r = land(fx, '10', pin, ['--dry-run']);
|
||||
expect(r.err).toBe('');
|
||||
expect(r.out).toContain('DRY-RUN: nothing merged');
|
||||
expect(remote(fx, 'main')).toBe(base);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('land --pin: refusals (nothing merged)', () => {
|
||||
const refused = (fx: Fx, r: { status: number | null; err: string }, code: string) => {
|
||||
expect(r.status).not.toBe(EXIT.ok);
|
||||
expect(r.err).toContain('(' + code + ')');
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
};
|
||||
|
||||
test('QUEUE_LAND_STAMPED: a head that bumps VERSION names the one-command fix', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '11', { edit: w => fs.writeFileSync(path.join(w, 'VERSION'), '1.2.4.0\n') });
|
||||
const r = land(fx, '11', pin);
|
||||
refused(fx, r, 'QUEUE_LAND_STAMPED');
|
||||
expect(r.err).toContain('git checkout $(git merge-base origin/main HEAD) -- VERSION');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_LAND_STAMPED: a head that edits the CHANGELOG or the package.json version', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '12', {
|
||||
edit: w => {
|
||||
fs.appendFileSync(path.join(w, 'CHANGELOG.md'), '\n- stray\n');
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(w, 'package.json'), 'utf8')); pkg.version = '9.9.9';
|
||||
fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
|
||||
},
|
||||
});
|
||||
const r = land(fx, '12', pin);
|
||||
refused(fx, r, 'QUEUE_LAND_STAMPED');
|
||||
expect(r.err).toContain('CHANGELOG.md');
|
||||
expect(r.err).toContain('package.json version');
|
||||
}, 90_000);
|
||||
|
||||
test('a package.json dependency edit is not a stamp', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '13', {
|
||||
edit: w => { const pkg = JSON.parse(fs.readFileSync(path.join(w, 'package.json'), 'utf8')); pkg.dependencies = { ...(pkg.dependencies ?? {}), 'left-pad': '1.3.0' }; fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); },
|
||||
});
|
||||
const r = land(fx, '13', pin);
|
||||
expect(r.err).toBe('');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_LAND_PIN_MOVED: the PR head is not the pin', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '14');
|
||||
fx.write(d => { d.prs['14'].headRefOid = 'a'.repeat(40); });
|
||||
refused(fx, land(fx, '14', pin), 'QUEUE_LAND_PIN_MOVED');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_RECEIPT_MISSING: no /ship receipt in the PR body', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '15', { shipped: false });
|
||||
refused(fx, land(fx, '15', pin), 'QUEUE_RECEIPT_MISSING');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_NOT_READY: no READY note at the pin', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '16', { ready: false });
|
||||
const r = land(fx, '16', pin);
|
||||
refused(fx, r, 'QUEUE_NOT_READY');
|
||||
expect(r.err).toContain('gstack-ship-queue ready --pr 16');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_LAND_CHECKS: a red check, a pending check, and a required check GitHub never scheduled', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '17');
|
||||
fx.write(d => { d.checks[pin] = { 'free-tests': 'success', 'Check VERSION is not stale vs queue': 'success', other: 'failure' }; });
|
||||
expect(land(fx, '17', pin).err).toMatch(/red at .*\(QUEUE_LAND_CHECKS\)/);
|
||||
fx.write(d => { d.checks[pin] = { 'free-tests': 'pending', 'Check VERSION is not stale vs queue': 'success' }; });
|
||||
expect(land(fx, '17', pin).err).toMatch(/pending at .*\(QUEUE_LAND_CHECKS\)/);
|
||||
fx.write(d => { d.checks[pin] = { 'free-tests': 'success' }; });
|
||||
const missing = land(fx, '17', pin);
|
||||
refused(fx, missing, 'QUEUE_LAND_CHECKS');
|
||||
expect(missing.err).toContain('Check VERSION is not stale vs queue');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_LAND_NOT_CLEAN: GitHub does not report the PR mergeable and clean', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '18');
|
||||
fx.write(d => { d.prs['18'].mergeable = 'CONFLICTING'; d.prs['18'].mergeStateStatus = 'DIRTY'; });
|
||||
const r = land(fx, '18', pin);
|
||||
refused(fx, r, 'QUEUE_LAND_NOT_CLEAN');
|
||||
expect(r.err).toContain('DIRTY');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_TRAIN_UNTRUSTED: a fork head', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '19', { fork: true });
|
||||
refused(fx, land(fx, '19', pin), 'QUEUE_TRAIN_UNTRUSTED');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_NOT_READY: the base policy merges through trains', () => {
|
||||
const fx = setup({ ...POLICY, merge: 'train' });
|
||||
const pin = openPr(fx, '20');
|
||||
const r = land(fx, '20', pin);
|
||||
refused(fx, r, 'QUEUE_NOT_READY');
|
||||
expect(r.err).toContain('merge: train');
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_LAND_UNVERIFIED: main\'s tree after the merge is not the pin merged onto its parent', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '21');
|
||||
fx.write(d => { d.strayOnMerge = true; });
|
||||
const r = land(fx, '21', pin);
|
||||
expect(r.status).not.toBe(EXIT.ok);
|
||||
expect(r.err).toContain('(QUEUE_LAND_UNVERIFIED)');
|
||||
expect(landNotes(fx)).toHaveLength(0);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('land --pin: fail closed and merge methods', () => {
|
||||
test('the old stamping land refuses under merge: direct, and a --pin with no sha is a usage error', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '23');
|
||||
const old = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '23', '--base', 'main', '--no-wait'], fx.env);
|
||||
expect(old.status).not.toBe(EXIT.ok);
|
||||
expect(old.err).toContain('merge: direct');
|
||||
expect(old.err).toContain('land --pr 23 --pin');
|
||||
expect(remote(fx, 'pr-23')).toBe(pin);
|
||||
const dangling = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '23', '--base', 'main', '--pin'], fx.env);
|
||||
expect(dangling.status).toBe(EXIT.usage);
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
}, 90_000);
|
||||
|
||||
test('a pin with no merge base on the base branch refuses instead of skipping the release-file check', () => {
|
||||
const fx = setup();
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', '--orphan', 'pr-24');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '9.9.9.9\n');
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'unrelated history');
|
||||
repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-24');
|
||||
const pin = repo.git('rev-parse', 'HEAD');
|
||||
fx.write(d => { d.prs['24'] = { state: 'OPEN', baseRefName: 'main', headRefName: 'pr-24', isCrossRepository: false, title: 'PR 24', body: shippedBody('24', pin) }; });
|
||||
markReady(fx, '24', pin);
|
||||
repo.git('checkout', '-q', '--detach', 'origin/main');
|
||||
const r = land(fx, '24', pin);
|
||||
expect(r.status).not.toBe(EXIT.ok);
|
||||
expect(r.err).toContain('cannot compute the merge base');
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
}, 90_000);
|
||||
|
||||
test('history: merge-only merges with --merge; a PR on another base refuses', () => {
|
||||
const fx = setup({ ...POLICY, history: 'merge-only' });
|
||||
const pin = openPr(fx, '25');
|
||||
fx.write(d => { d.prs['25'].baseRefName = 'dev'; });
|
||||
const other = land(fx, '25', pin);
|
||||
expect(other.err).toContain('targets dev, not main');
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
fx.write(d => { d.prs['25'].baseRefName = 'main'; });
|
||||
const r = land(fx, '25', pin);
|
||||
expect(r.err).toBe('');
|
||||
expect(fx.read().mergeArgs).toEqual(['25', '--merge', '--match-head-commit', pin]);
|
||||
}, 90_000);
|
||||
|
||||
test('land reads the policy after fetching the base: a required check main added since the last fetch is enforced', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '27');
|
||||
const other = fx.repo.root + '/other';
|
||||
fx.repo.git('clone', '-q', fx.repo.origin, other);
|
||||
fs.writeFileSync(path.join(other, '.gstack', 'ship-policy.json'), JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d', required_checks: [...REQUIRED, 'security-scan'] } }, null, 2) + '\n');
|
||||
fx.repo.git('-C', other, 'commit', '-qam', 'require security-scan');
|
||||
fx.repo.git('-C', other, 'push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
const r = land(fx, '27', pin, ['--dry-run']);
|
||||
expect(r.err).toContain('security-scan');
|
||||
expect(r.err).toContain('(QUEUE_LAND_CHECKS)');
|
||||
}, 90_000);
|
||||
|
||||
test('a rerun from a fresh clone after the branch was deleted fetches the pin from refs/pull/<n>/head and lands', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '28');
|
||||
expect(land(fx, '28', pin).status).toBe(EXIT.ok);
|
||||
const g = (...a: string[]) => fx.repo.git('--git-dir', fx.repo.origin, ...a);
|
||||
g('update-ref', 'refs/pull/28/head', pin);
|
||||
fx.write(d => { d.prs['28'].headRefOid = pin; });
|
||||
g('update-ref', '-d', 'refs/heads/pr-28');
|
||||
const fresh = fx.repo.root + '/fresh';
|
||||
fx.repo.git('clone', '-q', '--no-local', '--single-branch', '--branch', 'main', fx.repo.origin, fresh);
|
||||
expect(spawnCheck(fresh, pin)).toBe(false);
|
||||
const r = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '28', '--pin', pin, '--base', 'main'], fx.env, fresh);
|
||||
expect(r.err).toBe('');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toMatch(/^LANDED: /m);
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_LAND_STAMPED: a head that repoints .gstack/version-path', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '29', { edit: w => { fs.mkdirSync(path.join(w, '.gstack'), { recursive: true }); fs.writeFileSync(path.join(w, 'OTHER_VERSION'), fs.readFileSync(path.join(w, 'VERSION'))); fs.writeFileSync(path.join(w, '.gstack', 'version-path'), 'OTHER_VERSION\n'); fs.writeFileSync(path.join(w, 'VERSION'), '9.9.9.9\n'); } });
|
||||
const r = land(fx, '29', pin);
|
||||
expect(r.err).toContain('(QUEUE_LAND_STAMPED)');
|
||||
expect(r.err).toContain('.gstack/version-path');
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
}, 90_000);
|
||||
|
||||
test('merge: direct without changelog_fragments refuses with the key to add', () => {
|
||||
const fx = setup({ ...POLICY, changelog_fragments: null });
|
||||
const pin = openPr(fx, '26');
|
||||
const r = land(fx, '26', pin);
|
||||
expect(r.err).toContain('merge: direct needs changelog_fragments');
|
||||
expect(fx.read().mergeArgs).toBeUndefined();
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('merge: direct | train', () => {
|
||||
test('train build refuses under merge: direct and names land', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '22');
|
||||
const r = runBin(fx.repo, 'gstack-ship-queue', ['train', 'build', '--prs', '22@' + pin, '--base', 'main'], fx.env);
|
||||
expect(r.status).not.toBe(EXIT.ok);
|
||||
expect(r.err).toContain('merge: direct');
|
||||
expect(r.err).toContain('gstack-ship-queue land --pr <n> --pin <sha>');
|
||||
}, 90_000);
|
||||
|
||||
test('the key validates its values and needs min_gstack_version', () => {
|
||||
const errs = (o: Record<string, unknown>) => validatePolicy(o).map(e => e.path + ': ' + e.message).join('\n');
|
||||
expect(errs({ merge: 'direct', min_gstack_version: MERGE_KEY_VERSION })).toBe('');
|
||||
expect(errs({ merge: 'train' })).toBe('');
|
||||
expect(errs({ merge: 'sideways', min_gstack_version: MERGE_KEY_VERSION })).toContain('merge: must be one of direct | train');
|
||||
expect(errs({ merge: 'direct' })).toContain('introduced in gstack ' + MERGE_KEY_VERSION);
|
||||
});
|
||||
|
||||
test('the key floor follows the stamp: the train restamp moves MERGE_KEY_VERSION to the stamped 1.x.N, an N install reads a policy with min N, an N-1 install gets the upgrade message', () => {
|
||||
const root = path.resolve(import.meta.dir, '..');
|
||||
const fork = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim();
|
||||
const stamped = bumpVersion(fork, 'patch')!;
|
||||
const tentative = bumpVersion(fork, 'minor')!;
|
||||
const policy = JSON.parse(fs.readFileSync(path.join(root, '.gstack', 'ship-policy.json'), 'utf8'));
|
||||
const templates: string[] = policy.stamp_patterns;
|
||||
expect(policy.stamp_paths).toContain('lib/ship-policy.ts');
|
||||
expect(templates).toContain("_KEY_VERSION = '{version}'");
|
||||
expect(fs.readFileSync(path.join(root, 'lib', 'ship-policy.ts'), 'utf8')).toContain(`export const MERGE_KEY_VERSION = '${MERGE_KEY_VERSION}';`);
|
||||
const line = `export const MERGE_KEY_VERSION = '${tentative}';`;
|
||||
expect(tentativeMentions(new Map([['lib/ship-policy.ts', new Set([line])]]), fork, stamped, templates)).toEqual([tentative]);
|
||||
const out = rewriteStamps(line, tentative, stamped, templates);
|
||||
expect(out.count).toBe(1);
|
||||
expect(out.text).toBe(`export const MERGE_KEY_VERSION = '${stamped}';`);
|
||||
const errs = (min: string, running: string) => validatePolicy({ merge: 'direct', min_gstack_version: min }, { gstackVersion: running });
|
||||
expect(errs(MERGE_KEY_VERSION, MERGE_KEY_VERSION)).toEqual([]);
|
||||
const parts = MERGE_KEY_VERSION.split('.').map(Number);
|
||||
const at = parts.findLastIndex(n => n > 0);
|
||||
parts[at] = parts[at]! - 1;
|
||||
const older = parts.join('.');
|
||||
const tooOld = errs(MERGE_KEY_VERSION, older);
|
||||
expect(tooOld.map(e => e.code)).toContain('POLICY_GSTACK_TOO_OLD');
|
||||
expect(tooOld.map(e => e.message).join('\n')).toContain(`this policy needs gstack ${MERGE_KEY_VERSION} or later; this gstack is ${older}`);
|
||||
});
|
||||
|
||||
test("gstack's own policy does not set merge before the key's release exists (min_gstack_version must name a published version)", () => {
|
||||
const raw = JSON.parse(fs.readFileSync(path.resolve(import.meta.dir, '..', '.gstack', 'ship-policy.json'), 'utf8'));
|
||||
const fork = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim();
|
||||
if ('merge' in raw) expect(validatePolicy(raw, { gstackVersion: fork })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user