From 9e49d4f8121a12b180575e5ad767ceec2a87f4cd Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 1 Jun 2026 08:34:09 -0700 Subject: [PATCH] feat(land): enqueue-and-return default + first-time trunk.io onboarding On a merge queue, /land now enqueues the PR and returns by default, so you can /land a stack of ready PRs and walk away while the queue lands them. --watch opts into the blocking poll (and is what /land-and-deploy uses, since it deploys the result). No-queue repos still merge synchronously. Also: /land explains in plain English what a merge queue is and what it'll do before submitting (teacher mode on first encounter), and when no queue is configured it offers to set trunk.io up and hand-holds the whole flow. The onboarding lives in one shared {{MERGE_QUEUE_SETUP}} resolver included by both /land and /setup-deploy (DRY). Atomic .tmpl + gen:skill-docs regen. The postfail-ordering test is updated in the same commit because 4.3 was renamed (Wait -> enqueue/watch); it now matches the section number so the rename stays bisect-clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- land-and-deploy/SKILL.md | 9 +- land-and-deploy/SKILL.md.tmpl | 9 +- land/SKILL.md | 197 +++++++++++++++++++++++-- land/SKILL.md.tmpl | 115 +++++++++++++-- scripts/resolvers/index.ts | 2 + scripts/resolvers/merge-queue-setup.ts | 98 ++++++++++++ setup-deploy/SKILL.md | 89 +++++++++++ setup-deploy/SKILL.md.tmpl | 7 + test/helpers/touchfiles.ts | 6 +- test/land-and-deploy-postfail.test.ts | 10 +- 10 files changed, 509 insertions(+), 33 deletions(-) create mode 100644 scripts/resolvers/merge-queue-setup.ts diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 9f544168c..87a6f5247 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -1126,7 +1126,14 @@ Continue to Step 2. The entire "land" half — pre-flight, CI wait, VERSION-drift check, the pre-merge readiness gate, and the actual merge through the right regime (none / GitHub native -merge queue / trunk.io merge queue) — is owned by the `/land` skill. Run it now: +merge queue / trunk.io merge queue) — is owned by the `/land` skill. Run it now. + +**Run `/land` as if invoked with `--watch`.** `/land`'s default for a queue regime is +enqueue-and-return (hand the PR to the queue and come back) — but the deploy and revert +steps below need the *completed* merge and its SHA, so here you MUST block until the PR +actually lands. Take `/land`'s `--watch` branch at its Step 4.3 (`gstack-merge wait`, then +Step 5 `write-state`), not the enqueue-and-return branch. If the PR ejects or times out in +the queue, `/land` STOPs and so do you — there is nothing to deploy. Read the `/land` skill file at `~/.claude/skills/gstack/land/SKILL.md` using the Read tool. diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index d6949cd61..3ca16a0e9 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -308,7 +308,14 @@ Continue to Step 2. The entire "land" half — pre-flight, CI wait, VERSION-drift check, the pre-merge readiness gate, and the actual merge through the right regime (none / GitHub native -merge queue / trunk.io merge queue) — is owned by the `/land` skill. Run it now: +merge queue / trunk.io merge queue) — is owned by the `/land` skill. Run it now. + +**Run `/land` as if invoked with `--watch`.** `/land`'s default for a queue regime is +enqueue-and-return (hand the PR to the queue and come back) — but the deploy and revert +steps below need the *completed* merge and its SHA, so here you MUST block until the PR +actually lands. Take `/land`'s `--watch` branch at its Step 4.3 (`gstack-merge wait`, then +Step 5 `write-state`), not the enqueue-and-return branch. If the PR ejects or times out in +the queue, `/land` STOPs and so do you — there is nothing to deploy. {{INVOKE_SKILL:land}} diff --git a/land/SKILL.md b/land/SKILL.md index 39c250462..278d6a1b5 100644 --- a/land/SKILL.md +++ b/land/SKILL.md @@ -793,6 +793,9 @@ When the user types `/land`, run this skill. - `/land` — auto-detect the PR from the current branch - `/land #123` — land a specific PR number - `/land --fast` — skip the soft-warning confirmation when there are no blockers. `--fast` NEVER skips a real blocker (failing CI, merge conflict, failing free tests, an unconfirmed merge SHA). It only spares you the "warnings present, proceed?" prompt when everything that matters is green. +- `/land --watch` — for a **queue** regime (trunk / GitHub native), block and watch until the PR actually lands, instead of the default **enqueue-and-return**. Use it when you want to sit and confirm this one PR landed. (Combine freely, e.g. `/land #123 --fast --watch`.) + +**Default for a merge queue is enqueue-and-return.** If the repo uses a queue, `/land` hands the PR to the queue, tells you where to watch, and returns — so you can `/land` a whole stack of ready PRs and walk away while the queue lands them. `--watch` opts into blocking. A no-queue repo always merges synchronously (there's nothing to queue). ## Non-interactive philosophy — with one critical gate @@ -814,8 +817,8 @@ This is a **mostly automated** workflow. The user said `/land`, which means DO I ## Voice & Tone - **Narrate what's happening now.** "Checking CI status..." not silence. - **Explain why before a gate.** "A merge to main can't be undone without a revert, so I check X first." -- **Be specific.** "Your repo uses the trunk.io merge queue — I'll enqueue and watch it" not "merging." -- **First run = teacher mode**; subsequent runs = brief status updates. +- **Be specific.** "Your repo uses the trunk.io merge queue — I'll enqueue this PR and the queue will land it" not "merging." +- **First run = teacher mode** (explain what a merge queue is and what enqueue-and-return means before doing it); subsequent runs = brief status updates. --- @@ -1081,10 +1084,137 @@ Resolution order (platform-agnostic rule — the project owns its config, gstack It returns `{"regime":"none|github|trunk","source":"...","base":"..."}`. Detection uses the queue's own GitHub status check (`Trunk Merge Queue ()` → trunk), branch-protection merge queue (→ github), and `.trunk/trunk.yaml` `merge:` as a secondary signal. A bare `.trunk/` directory is NOT treated as trunk (the `trunk check` linter uses the same dir). 3. **Ask once, then persist** — if there is no config AND detection returns `none` but the user expected a queue (or detection is ambiguous), ask via AskUserQuestion which regime to use, then write a `## Merge Configuration` section to CLAUDE.md so we never ask again. (You can also point them at `/setup-deploy`, which writes this section.) -Tell the user which regime you'll use and why: e.g. "Your repo uses the trunk.io merge queue (detected from the `Trunk Merge Queue (main)` check). I'll enqueue the PR and watch the queue." +Tell the user which regime you'll use and why: e.g. "Your repo uses the trunk.io merge queue (detected from the `Trunk Merge Queue (main)` check)." Record the start timestamp. +### 4.1a: Explain what's about to happen (queue regimes) + +If the regime is `github` or `trunk`, **before submitting**, tell the user plainly what a +merge queue is and what `/land` will do. Full version on the first encounter for this repo +(no `## Merge Configuration` was present), one line on repeats. Gloss "merge queue," +"enqueue," and "optimistic merge" on first use. + +First-encounter script (adapt to the detected regime): + +> "Heads up on how this lands. Your repo uses a **merge queue** (a system that merges +> PRs for you instead of you clicking merge). So I won't merge right now — I'll **enqueue** +> this PR (hand it to the queue) and return. The queue tests it and lands it on `` +> on its own, in parallel with other queued PRs, and **optimistically** (a later PR that +> already contains this change can rescue it from a flaky test). The point: you can run +> `/land` on a whole stack of ready PRs and walk away — they'll all make it onto `` +> without you babysitting. I'll tell you where to watch. (Want me to block and watch this +> one land instead? Re-run with `/land --watch`.)" + +Repeat-encounter: "Enqueuing to the {trunk/GitHub} queue — it'll land on ``. (`--watch` to block.)" + +### 4.1b: Offer the merge queue (no-queue repos, first time) + +If the regime resolved to `none` AND there was no `## Merge Configuration` (i.e. we did +not detect a queue and the user never configured one), surface the option once — don't +force it. Use AskUserQuestion: + +- **Re-ground:** "This repo merges directly (no merge queue), so I'll squash-merge this PR + now. If you regularly have several PRs ready at once, trunk.io's merge queue can land + them all in parallel without you merging each one by hand and waiting. Want me to set it + up? It's a one-time setup and I'll walk you through every step." +- **RECOMMENDATION:** Choose A if you often juggle multiple ready PRs; choose B to just + merge this one now. +- A) Walk me through setting up the trunk.io merge queue first (Completeness: 10/10) +- B) Just merge this PR directly now — maybe later (Completeness: 7/10) + +**If A:** Run the hand-held onboarding below, then re-resolve the regime (it should now be +`trunk`) and continue. **If B:** continue with the `none` path. Either way, do not re-ask on +later runs (the choice, or the written `## Merge Configuration`, settles it). + +### Set up a merge queue with trunk.io (first-time, hand-held) + +**What a merge queue is, in plain English.** Normally you merge one PR, wait for +it to land, merge the next, wait again — babysitting a line of PRs into the base +branch one at a time. A **merge queue** flips that: you *enqueue* each ready PR +and walk away. Trunk tests them (in parallel, and **optimistically** — a later PR +that already contains an earlier change can rescue it from a flaky failure) and +**lands them on the base branch for you**, in a safe order. You queue ten PRs in +a row, close your laptop, and they all make it onto the base branch without you. + +That is exactly the workflow this unlocks: `/land` on each PR, then go do +something else. + +**Before you start:** this needs a trunk.io account (the free tier covers small +teams) and admin access to the GitHub repo. It's a one-time setup. I'll walk each +step and explain *why*, and verify what I can with `gh`. + +**Step 1 — Create / sign in to trunk.io.** +Open https://app.trunk.io and sign in with GitHub. *(Why: the queue config and +dashboard live in Trunk's web app, not in your repo — there's no `trunk.yaml` +merge section to commit.)* + +**Step 2 — Install the Trunk GitHub App on this repo.** +In app.trunk.io → **Merge Queue** → **Create New Queue** → install the GitHub +App, select this repo, approve permissions. *(Why: the App is what lets the +`trunk-io` bot test on throwaway branches and push the final merge. Mandatory — +nothing works without it.)* +Verify the App can see the repo: +```bash +gh api "/repos///installation" --jq '.app_slug' 2>/dev/null || echo "App not detected yet" +``` + +**Step 3 — Create a queue for this repo + base branch.** +In the same flow, pick this repo and target branch ``, click **Create +Queue**. *(Why: a queue is scoped to one branch — you're queuing merges into +``.)* + +**Step 4 — Adjust branch protection (3 changes).** +In GitHub → Settings → Branches → the `` rule: +- **Allow the `trunk-io` bot to push to the protected branch.** *(Why: Trunk's + bot performs the actual merge; without push rights it can't land anything.)* +- **Disable "Require branches to be up to date before merging."** *(Why: Trunk + tests each PR against the others in the queue, so GitHub's own up-to-date gate + would fight it.)* +- **Exclude `trunk-merge/*` and `trunk-temp/*` from protection.** *(Why: those + are the throwaway branches Trunk tests on; protecting them blocks testing.)* + +**Step 5 — Turn on the optimizations that make "queue many, walk away" real.** +In app.trunk.io → your repo → Merge Queue → Settings, enable: +- **Optimistic Merge Queue** + **Pending Failure Depth ≥ 1** — keeps testing + later PRs while an earlier one is in "pending failure," and auto-recovers when a + later PR proves the failure was a flake. *(Why: one flaky PR doesn't stall the + whole line.)* +- **Parallel** — non-overlapping PRs test in independent lanes at the same time. + *(Why: throughput; ten unrelated PRs don't go one-at-a-time.)* +- **Batching** — lands compatible PRs together with auto-bisection on failure. + *(Why: fewer CI runs, and a bad PR doesn't eject the whole batch.)* +- **Merge Method** — pick Squash / Merge Commit / Rebase to match your repo. *(Why: + it controls what the landed commit looks like; `/land` handles all three.)* + +**Step 6 — Pick how PRs get enqueued.** +The simplest works immediately: commenting **`/trunk merge`** on a PR. `/land` +uses that by default — zero extra auth, because the GitHub App is already +installed. *(Optional upgrades: set an "enqueue by label" name in the web UI, run +`trunk login` to use the `trunk` CLI, or set `$TRUNK_API_TOKEN` for the REST +API — `/land` will prefer those when present.)* + +**Step 7 — Persist the choice so I never ask again.** +I'll write `Merge queue: trunk` into a `## Merge Configuration` section of +CLAUDE.md. *(Why: `/land` reads it and skips detection from then on.)* + +**Step 8 — Verify end-to-end.** +Open any test PR and run `/land`. You should see a **`Trunk Merge Queue +()`** check appear, move Queued → Testing → Merged, and the PR land on +`` without you touching GitHub: +```bash +gh pr checks --json name,state | grep -i "Trunk Merge Queue" || echo "no queue check yet — recheck Steps 2-4" +``` + +Full docs: https://docs.trunk.io/merge-queue/getting-started + +Once this is done, the payoff: queue up all your ready PRs with `/land`, walk +away, and trunk lands them on `` for you. + +When the onboarding completes, write `Merge queue: trunk` into a `## Merge Configuration` +section of CLAUDE.md (create the section if absent) so `/land` never has to ask again, then +re-run `gstack-merge detect` to confirm, and continue with the `trunk` regime. + ### 4.2: Submit ```bash @@ -1143,25 +1273,46 @@ gh pr view --json autoMergeRequest -q .autoMergeRequest **Hard rule: never call `gh pr merge` a second time** after a non-zero exit. Server state is authoritative. -### 4.3: Wait for it to land +### 4.3: Enqueue-and-return (default) or watch until landed + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +``` + +**Regime `none`** — the direct squash in 4.2 already merged synchronously. Go straight to Step 5 (write-state confirms the SHA). + +**Regime `github` / `trunk`, DEFAULT (no `--watch`)** — confirm the queue actually picked the PR up, then return; the queue lands it: + +```bash +~/.claude/skills/gstack/bin/gstack-merge confirm-enqueue --regime --pr --base --slug "$SLUG" +``` + +- **exit 0 / `ENQUEUED=...`** — the PR is in the queue and will land on `` on its own. Do NOT run Step 5 (there is no merge SHA yet — `confirm-enqueue` wrote a lightweight `last-enqueue.json`). Go to Step 6's **enqueue summary**, and surface the `WATCH_CHECK` / `WATCH_DASHBOARD` lines from the output so the user knows where to look. +- **`TRUNK_ENQUEUE_TIMEOUT`** — **STOP.** "I posted `/trunk merge` but Trunk never picked it up. Confirm the Trunk GitHub App is installed on this repo and 'GitHub commands' is enabled (run `/land` on a no-queue repo, or `/setup-deploy`, for the setup walkthrough), then run `/land` again." +- **`ENQUEUE_UNCONFIRMED`** (github) — **STOP.** "GitHub auto-merge didn't enable on the PR. Check the repo's merge-queue / auto-merge settings, then run `/land` again." + +**Regime `github` / `trunk` WITH `--watch`** — block until it actually lands: ```bash ~/.claude/skills/gstack/bin/gstack-merge wait --regime --pr --base ``` -The helper polls the uniform landing signal (`gh pr view state` + the merge-queue status check) and prints progress. For the trunk regime it first confirms the PR was actually picked up (the `Trunk Merge Queue ()` check appears) — a posted `/trunk merge` comment is silently inert if the GitHub App isn't installed. - -Handle the exit: -- **exit 0 / `LAND_STATUS=landed`** — it merged. Continue to Step 5. +The helper polls the uniform landing signal (`gh pr view state` + the merge-queue status check). For trunk it first confirms pickup (the `Trunk Merge Queue ()` check appears) — a posted `/trunk merge` comment is silently inert if the GitHub App isn't installed. Handle the exit: +- **`LAND_STATUS=landed`** — continue to Step 5. - **`LAND_STATUS=ejected`** — the queue rejected the PR (a CI check failed on the merge candidate, or a conflict with another queued PR). **STOP.** "The merge queue ejected this PR: {reason}. Check the queue page — usually a check failed on the merge commit. Fix and run `/land` again." - **`LAND_STATUS=closed`** — **STOP.** "The PR was closed without merging." -- **`TRUNK_ENQUEUE_TIMEOUT`** — **STOP.** "I posted `/trunk merge` but Trunk never picked it up. Confirm the Trunk GitHub App is installed on this repo and 'GitHub commands' is enabled, then run `/land` again." +- **`TRUNK_ENQUEUE_TIMEOUT`** — **STOP.** (same guidance as above.) - **timeout** — **STOP.** "The merge has been pending for {duration}. Something may be stuck — check the GitHub Actions tab and the merge-queue page." --- ## Step 5: Confirm landing and write the handoff +**Run this step only when the PR actually merged** — i.e. the `none` regime, or a `--watch` +run that returned `LAND_STATUS=landed`. In the default enqueue-and-return path you already +returned at 4.3 with the PR sitting in the queue (no merge SHA yet), so skip to Step 6's +**enqueue summary**. + A merge isn't done until the commit is on the base branch with a known SHA. This is also the **handoff** the deploy half needs (its `git revert` and deploy-workflow match both need the merge SHA), so `/land` writes it as a file, not just a log line. ```bash @@ -1184,9 +1335,30 @@ and prints a human echo: `LANDED: pr=#NNN sha= regime= base= +Branch: <head> → <base> +Regime: <github / trunk> +Status: In the merge queue — it'll land on <base> automatically +Watch: <WATCH_CHECK> (and <WATCH_DASHBOARD> for trunk) + +VERDICT: ENQUEUED — no action needed; the queue will land it. +``` + +Then tell the user, in plain English: "You don't need to wait. Queue up your other ready +PRs with `/land` the same way and walk away — the queue lands them all on `<base>`. To +block and watch one land instead, run `/land --watch`." (Skip the walk-away pitch if this +was invoked by `/land-and-deploy`.) + +### Land summary (none regime, or `--watch` that landed) ``` LAND REPORT @@ -1212,6 +1384,7 @@ Then suggest the natural next step: "Want to deploy and verify this in productio - **Never skip CI.** Failing or pending checks gate the merge. - **Never call `gh pr merge` twice** after a non-zero exit — server state is authoritative (see 4.2). Related: cli/cli#3442, cli/cli#13380. - **Trunk owns the trunk path.** In the trunk regime, never run `gh pr merge` and never pass `--delete-branch`. -- **Landing means a SHA on the base branch.** Don't report success until `write-state` confirms it (Step 5). A null SHA silently kills the deploy half's revert. +- **A merge queue means enqueue-and-return, not babysit.** For a queue regime the default is to enqueue and return so the user can `/land` a whole stack and walk away; only `--watch` blocks. Never block-by-default on a queue — it defeats the point of the queue. +- **Landing means a SHA on the base branch.** In the `none` path or a `--watch` run, don't report success until `write-state` confirms it (Step 5). A null SHA silently kills the deploy half's revert. (In enqueue-and-return there is intentionally no SHA yet — that's why `/land-and-deploy` always runs `/land --watch`.) - **Detect, don't assume.** Resolve the regime from config → live detection → ask-once-and-persist. The same `gstack-merge detect` is what `/land-and-deploy`'s dry-run uses, so the two never disagree. - **Narrate the journey.** The user should always know what just happened, what's happening now, and what's next. diff --git a/land/SKILL.md.tmpl b/land/SKILL.md.tmpl index 8a5dc482a..f72b187ee 100644 --- a/land/SKILL.md.tmpl +++ b/land/SKILL.md.tmpl @@ -43,6 +43,9 @@ When the user types `/land`, run this skill. - `/land` — auto-detect the PR from the current branch - `/land #123` — land a specific PR number - `/land --fast` — skip the soft-warning confirmation when there are no blockers. `--fast` NEVER skips a real blocker (failing CI, merge conflict, failing free tests, an unconfirmed merge SHA). It only spares you the "warnings present, proceed?" prompt when everything that matters is green. +- `/land --watch` — for a **queue** regime (trunk / GitHub native), block and watch until the PR actually lands, instead of the default **enqueue-and-return**. Use it when you want to sit and confirm this one PR landed. (Combine freely, e.g. `/land #123 --fast --watch`.) + +**Default for a merge queue is enqueue-and-return.** If the repo uses a queue, `/land` hands the PR to the queue, tells you where to watch, and returns — so you can `/land` a whole stack of ready PRs and walk away while the queue lands them. `--watch` opts into blocking. A no-queue repo always merges synchronously (there's nothing to queue). ## Non-interactive philosophy — with one critical gate @@ -64,8 +67,8 @@ This is a **mostly automated** workflow. The user said `/land`, which means DO I ## Voice & Tone - **Narrate what's happening now.** "Checking CI status..." not silence. - **Explain why before a gate.** "A merge to main can't be undone without a revert, so I check X first." -- **Be specific.** "Your repo uses the trunk.io merge queue — I'll enqueue and watch it" not "merging." -- **First run = teacher mode**; subsequent runs = brief status updates. +- **Be specific.** "Your repo uses the trunk.io merge queue — I'll enqueue this PR and the queue will land it" not "merging." +- **First run = teacher mode** (explain what a merge queue is and what enqueue-and-return means before doing it); subsequent runs = brief status updates. --- @@ -331,10 +334,55 @@ Resolution order (platform-agnostic rule — the project owns its config, gstack It returns `{"regime":"none|github|trunk","source":"...","base":"..."}`. Detection uses the queue's own GitHub status check (`Trunk Merge Queue (<base>)` → trunk), branch-protection merge queue (→ github), and `.trunk/trunk.yaml` `merge:` as a secondary signal. A bare `.trunk/` directory is NOT treated as trunk (the `trunk check` linter uses the same dir). 3. **Ask once, then persist** — if there is no config AND detection returns `none` but the user expected a queue (or detection is ambiguous), ask via AskUserQuestion which regime to use, then write a `## Merge Configuration` section to CLAUDE.md so we never ask again. (You can also point them at `/setup-deploy`, which writes this section.) -Tell the user which regime you'll use and why: e.g. "Your repo uses the trunk.io merge queue (detected from the `Trunk Merge Queue (main)` check). I'll enqueue the PR and watch the queue." +Tell the user which regime you'll use and why: e.g. "Your repo uses the trunk.io merge queue (detected from the `Trunk Merge Queue (main)` check)." Record the start timestamp. +### 4.1a: Explain what's about to happen (queue regimes) + +If the regime is `github` or `trunk`, **before submitting**, tell the user plainly what a +merge queue is and what `/land` will do. Full version on the first encounter for this repo +(no `## Merge Configuration` was present), one line on repeats. Gloss "merge queue," +"enqueue," and "optimistic merge" on first use. + +First-encounter script (adapt to the detected regime): + +> "Heads up on how this lands. Your repo uses a **merge queue** (a system that merges +> PRs for you instead of you clicking merge). So I won't merge right now — I'll **enqueue** +> this PR (hand it to the queue) and return. The queue tests it and lands it on `<base>` +> on its own, in parallel with other queued PRs, and **optimistically** (a later PR that +> already contains this change can rescue it from a flaky test). The point: you can run +> `/land` on a whole stack of ready PRs and walk away — they'll all make it onto `<base>` +> without you babysitting. I'll tell you where to watch. (Want me to block and watch this +> one land instead? Re-run with `/land --watch`.)" + +Repeat-encounter: "Enqueuing to the {trunk/GitHub} queue — it'll land on `<base>`. (`--watch` to block.)" + +### 4.1b: Offer the merge queue (no-queue repos, first time) + +If the regime resolved to `none` AND there was no `## Merge Configuration` (i.e. we did +not detect a queue and the user never configured one), surface the option once — don't +force it. Use AskUserQuestion: + +- **Re-ground:** "This repo merges directly (no merge queue), so I'll squash-merge this PR + now. If you regularly have several PRs ready at once, trunk.io's merge queue can land + them all in parallel without you merging each one by hand and waiting. Want me to set it + up? It's a one-time setup and I'll walk you through every step." +- **RECOMMENDATION:** Choose A if you often juggle multiple ready PRs; choose B to just + merge this one now. +- A) Walk me through setting up the trunk.io merge queue first (Completeness: 10/10) +- B) Just merge this PR directly now — maybe later (Completeness: 7/10) + +**If A:** Run the hand-held onboarding below, then re-resolve the regime (it should now be +`trunk`) and continue. **If B:** continue with the `none` path. Either way, do not re-ask on +later runs (the choice, or the written `## Merge Configuration`, settles it). + +{{MERGE_QUEUE_SETUP}} + +When the onboarding completes, write `Merge queue: trunk` into a `## Merge Configuration` +section of CLAUDE.md (create the section if absent) so `/land` never has to ask again, then +re-run `gstack-merge detect` to confirm, and continue with the `trunk` regime. + ### 4.2: Submit ```bash @@ -393,25 +441,46 @@ gh pr view --json autoMergeRequest -q .autoMergeRequest **Hard rule: never call `gh pr merge` a second time** after a non-zero exit. Server state is authoritative. -### 4.3: Wait for it to land +### 4.3: Enqueue-and-return (default) or watch until landed + +```bash +{{SLUG_EVAL}} +``` + +**Regime `none`** — the direct squash in 4.2 already merged synchronously. Go straight to Step 5 (write-state confirms the SHA). + +**Regime `github` / `trunk`, DEFAULT (no `--watch`)** — confirm the queue actually picked the PR up, then return; the queue lands it: + +```bash +~/.claude/skills/gstack/bin/gstack-merge confirm-enqueue --regime <regime> --pr <NNN> --base <base> --slug "$SLUG" +``` + +- **exit 0 / `ENQUEUED=...`** — the PR is in the queue and will land on `<base>` on its own. Do NOT run Step 5 (there is no merge SHA yet — `confirm-enqueue` wrote a lightweight `last-enqueue.json`). Go to Step 6's **enqueue summary**, and surface the `WATCH_CHECK` / `WATCH_DASHBOARD` lines from the output so the user knows where to look. +- **`TRUNK_ENQUEUE_TIMEOUT`** — **STOP.** "I posted `/trunk merge` but Trunk never picked it up. Confirm the Trunk GitHub App is installed on this repo and 'GitHub commands' is enabled (run `/land` on a no-queue repo, or `/setup-deploy`, for the setup walkthrough), then run `/land` again." +- **`ENQUEUE_UNCONFIRMED`** (github) — **STOP.** "GitHub auto-merge didn't enable on the PR. Check the repo's merge-queue / auto-merge settings, then run `/land` again." + +**Regime `github` / `trunk` WITH `--watch`** — block until it actually lands: ```bash ~/.claude/skills/gstack/bin/gstack-merge wait --regime <regime> --pr <NNN> --base <base> ``` -The helper polls the uniform landing signal (`gh pr view state` + the merge-queue status check) and prints progress. For the trunk regime it first confirms the PR was actually picked up (the `Trunk Merge Queue (<base>)` check appears) — a posted `/trunk merge` comment is silently inert if the GitHub App isn't installed. - -Handle the exit: -- **exit 0 / `LAND_STATUS=landed`** — it merged. Continue to Step 5. +The helper polls the uniform landing signal (`gh pr view state` + the merge-queue status check). For trunk it first confirms pickup (the `Trunk Merge Queue (<base>)` check appears) — a posted `/trunk merge` comment is silently inert if the GitHub App isn't installed. Handle the exit: +- **`LAND_STATUS=landed`** — continue to Step 5. - **`LAND_STATUS=ejected`** — the queue rejected the PR (a CI check failed on the merge candidate, or a conflict with another queued PR). **STOP.** "The merge queue ejected this PR: {reason}. Check the queue page — usually a check failed on the merge commit. Fix and run `/land` again." - **`LAND_STATUS=closed`** — **STOP.** "The PR was closed without merging." -- **`TRUNK_ENQUEUE_TIMEOUT`** — **STOP.** "I posted `/trunk merge` but Trunk never picked it up. Confirm the Trunk GitHub App is installed on this repo and 'GitHub commands' is enabled, then run `/land` again." +- **`TRUNK_ENQUEUE_TIMEOUT`** — **STOP.** (same guidance as above.) - **timeout** — **STOP.** "The merge has been pending for {duration}. Something may be stuck — check the GitHub Actions tab and the merge-queue page." --- ## Step 5: Confirm landing and write the handoff +**Run this step only when the PR actually merged** — i.e. the `none` regime, or a `--watch` +run that returned `LAND_STATUS=landed`. In the default enqueue-and-return path you already +returned at 4.3 with the PR sitting in the queue (no merge SHA yet), so skip to Step 6's +**enqueue summary**. + A merge isn't done until the commit is on the base branch with a known SHA. This is also the **handoff** the deploy half needs (its `git revert` and deploy-workflow match both need the merge SHA), so `/land` writes it as a file, not just a log line. ```bash @@ -434,9 +503,30 @@ and prints a human echo: `LANDED: pr=#NNN sha=<oid> regime=<regime> base=<branch --- -## Step 6: Land summary +## Step 6: Summary -When run standalone, print a short summary (skip the deploy framing — there is none here): +### Enqueue summary (default queue path — the PR is in the queue, not yet landed) + +When 4.3 returned `ENQUEUED=...`, print this and stop — the queue does the rest: + +``` +ENQUEUED +════════ +PR: #<number> — <title> +Branch: <head> → <base> +Regime: <github / trunk> +Status: In the merge queue — it'll land on <base> automatically +Watch: <WATCH_CHECK> (and <WATCH_DASHBOARD> for trunk) + +VERDICT: ENQUEUED — no action needed; the queue will land it. +``` + +Then tell the user, in plain English: "You don't need to wait. Queue up your other ready +PRs with `/land` the same way and walk away — the queue lands them all on `<base>`. To +block and watch one land instead, run `/land --watch`." (Skip the walk-away pitch if this +was invoked by `/land-and-deploy`.) + +### Land summary (none regime, or `--watch` that landed) ``` LAND REPORT @@ -462,6 +552,7 @@ Then suggest the natural next step: "Want to deploy and verify this in productio - **Never skip CI.** Failing or pending checks gate the merge. - **Never call `gh pr merge` twice** after a non-zero exit — server state is authoritative (see 4.2). Related: cli/cli#3442, cli/cli#13380. - **Trunk owns the trunk path.** In the trunk regime, never run `gh pr merge` and never pass `--delete-branch`. -- **Landing means a SHA on the base branch.** Don't report success until `write-state` confirms it (Step 5). A null SHA silently kills the deploy half's revert. +- **A merge queue means enqueue-and-return, not babysit.** For a queue regime the default is to enqueue and return so the user can `/land` a whole stack and walk away; only `--watch` blocks. Never block-by-default on a queue — it defeats the point of the queue. +- **Landing means a SHA on the base branch.** In the `none` path or a `--watch` run, don't report success until `write-state` confirms it (Step 5). A null SHA silently kills the deploy half's revert. (In enqueue-and-return there is intentionally no SHA yet — that's why `/land-and-deploy` always runs `/land --watch`.) - **Detect, don't assume.** Resolve the regime from config → live detection → ask-once-and-persist. The same `gstack-merge detect` is what `/land-and-deploy`'s dry-run uses, so the two never disagree. - **Narrate the journey.** The user should always know what just happened, what's happening now, and what's next. diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index 1c8d23b7f..3ae55a61c 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -36,8 +36,10 @@ import { generateMakePdfSetup } from './make-pdf'; import { generateTasksSectionEmit, generateTasksSectionAggregate } from './tasks-section'; import { SECTION, SECTION_INDEX } from './sections'; import { generateRedactTaxonomyTable, generateRedactInvocationBlock } from './redact-doc'; +import { generateMergeQueueSetup } from './merge-queue-setup'; export const RESOLVERS: Record<string, ResolverValue> = { + MERGE_QUEUE_SETUP: generateMergeQueueSetup, SLUG_EVAL: generateSlugEval, SLUG_SETUP: generateSlugSetup, REDACT_TAXONOMY_TABLE: generateRedactTaxonomyTable, diff --git a/scripts/resolvers/merge-queue-setup.ts b/scripts/resolvers/merge-queue-setup.ts new file mode 100644 index 000000000..694952e56 --- /dev/null +++ b/scripts/resolvers/merge-queue-setup.ts @@ -0,0 +1,98 @@ +import type { TemplateContext } from './types'; + +/** + * {{MERGE_QUEUE_SETUP}} — the authoritative, teacher-mode trunk.io merge-queue + * onboarding. Included by BOTH /setup-deploy (## Merge Configuration) and + * /land's first-time branch so the guide lives in exactly one place (DRY) and + * /land can hand-hold inline without making the user stop and run another skill. + * + * Grounded in a full read of docs.trunk.io/merge-queue (2026-05): config is + * server-side (app.trunk.io), the GitHub App is mandatory, trunk posts a + * "Trunk Merge Queue (<base>)" status check, and the `/trunk merge` PR comment + * enqueues with zero extra auth. + */ +export function generateMergeQueueSetup(_ctx: TemplateContext): string { + return `### Set up a merge queue with trunk.io (first-time, hand-held) + +**What a merge queue is, in plain English.** Normally you merge one PR, wait for +it to land, merge the next, wait again — babysitting a line of PRs into the base +branch one at a time. A **merge queue** flips that: you *enqueue* each ready PR +and walk away. Trunk tests them (in parallel, and **optimistically** — a later PR +that already contains an earlier change can rescue it from a flaky failure) and +**lands them on the base branch for you**, in a safe order. You queue ten PRs in +a row, close your laptop, and they all make it onto the base branch without you. + +That is exactly the workflow this unlocks: \`/land\` on each PR, then go do +something else. + +**Before you start:** this needs a trunk.io account (the free tier covers small +teams) and admin access to the GitHub repo. It's a one-time setup. I'll walk each +step and explain *why*, and verify what I can with \`gh\`. + +**Step 1 — Create / sign in to trunk.io.** +Open https://app.trunk.io and sign in with GitHub. *(Why: the queue config and +dashboard live in Trunk's web app, not in your repo — there's no \`trunk.yaml\` +merge section to commit.)* + +**Step 2 — Install the Trunk GitHub App on this repo.** +In app.trunk.io → **Merge Queue** → **Create New Queue** → install the GitHub +App, select this repo, approve permissions. *(Why: the App is what lets the +\`trunk-io\` bot test on throwaway branches and push the final merge. Mandatory — +nothing works without it.)* +Verify the App can see the repo: +\`\`\`bash +gh api "/repos/<owner>/<repo>/installation" --jq '.app_slug' 2>/dev/null || echo "App not detected yet" +\`\`\` + +**Step 3 — Create a queue for this repo + base branch.** +In the same flow, pick this repo and target branch \`<base>\`, click **Create +Queue**. *(Why: a queue is scoped to one branch — you're queuing merges into +\`<base>\`.)* + +**Step 4 — Adjust branch protection (3 changes).** +In GitHub → Settings → Branches → the \`<base>\` rule: +- **Allow the \`trunk-io\` bot to push to the protected branch.** *(Why: Trunk's + bot performs the actual merge; without push rights it can't land anything.)* +- **Disable "Require branches to be up to date before merging."** *(Why: Trunk + tests each PR against the others in the queue, so GitHub's own up-to-date gate + would fight it.)* +- **Exclude \`trunk-merge/*\` and \`trunk-temp/*\` from protection.** *(Why: those + are the throwaway branches Trunk tests on; protecting them blocks testing.)* + +**Step 5 — Turn on the optimizations that make "queue many, walk away" real.** +In app.trunk.io → your repo → Merge Queue → Settings, enable: +- **Optimistic Merge Queue** + **Pending Failure Depth ≥ 1** — keeps testing + later PRs while an earlier one is in "pending failure," and auto-recovers when a + later PR proves the failure was a flake. *(Why: one flaky PR doesn't stall the + whole line.)* +- **Parallel** — non-overlapping PRs test in independent lanes at the same time. + *(Why: throughput; ten unrelated PRs don't go one-at-a-time.)* +- **Batching** — lands compatible PRs together with auto-bisection on failure. + *(Why: fewer CI runs, and a bad PR doesn't eject the whole batch.)* +- **Merge Method** — pick Squash / Merge Commit / Rebase to match your repo. *(Why: + it controls what the landed commit looks like; \`/land\` handles all three.)* + +**Step 6 — Pick how PRs get enqueued.** +The simplest works immediately: commenting **\`/trunk merge\`** on a PR. \`/land\` +uses that by default — zero extra auth, because the GitHub App is already +installed. *(Optional upgrades: set an "enqueue by label" name in the web UI, run +\`trunk login\` to use the \`trunk\` CLI, or set \`$TRUNK_API_TOKEN\` for the REST +API — \`/land\` will prefer those when present.)* + +**Step 7 — Persist the choice so I never ask again.** +I'll write \`Merge queue: trunk\` into a \`## Merge Configuration\` section of +CLAUDE.md. *(Why: \`/land\` reads it and skips detection from then on.)* + +**Step 8 — Verify end-to-end.** +Open any test PR and run \`/land\`. You should see a **\`Trunk Merge Queue +(<base>)\`** check appear, move Queued → Testing → Merged, and the PR land on +\`<base>\` without you touching GitHub: +\`\`\`bash +gh pr checks <test-pr> --json name,state | grep -i "Trunk Merge Queue" || echo "no queue check yet — recheck Steps 2-4" +\`\`\` + +Full docs: https://docs.trunk.io/merge-queue/getting-started + +Once this is done, the payoff: queue up all your ready PRs with \`/land\`, walk +away, and trunk lands them on \`<base>\` for you.`; +} diff --git a/setup-deploy/SKILL.md b/setup-deploy/SKILL.md index 85352f881..7b5958b6b 100644 --- a/setup-deploy/SKILL.md +++ b/setup-deploy/SKILL.md @@ -917,6 +917,95 @@ section if it exists, or append it. Keep it separate from Deploy Configuration s `/land` reads the `Merge queue:` line to pick its submit path. If you skip this section, `/land` falls back to live detection and asks once. +**If the user chose `trunk` and the queue isn't set up yet** (no `Trunk Merge Queue (<base>)` +check on recent PRs — check with `gh pr checks` or `gh api`), walk them through the +one-time onboarding below before writing `Merge queue: trunk`. If they chose `none` or +`github`, skip the onboarding. + +### Set up a merge queue with trunk.io (first-time, hand-held) + +**What a merge queue is, in plain English.** Normally you merge one PR, wait for +it to land, merge the next, wait again — babysitting a line of PRs into the base +branch one at a time. A **merge queue** flips that: you *enqueue* each ready PR +and walk away. Trunk tests them (in parallel, and **optimistically** — a later PR +that already contains an earlier change can rescue it from a flaky failure) and +**lands them on the base branch for you**, in a safe order. You queue ten PRs in +a row, close your laptop, and they all make it onto the base branch without you. + +That is exactly the workflow this unlocks: `/land` on each PR, then go do +something else. + +**Before you start:** this needs a trunk.io account (the free tier covers small +teams) and admin access to the GitHub repo. It's a one-time setup. I'll walk each +step and explain *why*, and verify what I can with `gh`. + +**Step 1 — Create / sign in to trunk.io.** +Open https://app.trunk.io and sign in with GitHub. *(Why: the queue config and +dashboard live in Trunk's web app, not in your repo — there's no `trunk.yaml` +merge section to commit.)* + +**Step 2 — Install the Trunk GitHub App on this repo.** +In app.trunk.io → **Merge Queue** → **Create New Queue** → install the GitHub +App, select this repo, approve permissions. *(Why: the App is what lets the +`trunk-io` bot test on throwaway branches and push the final merge. Mandatory — +nothing works without it.)* +Verify the App can see the repo: +```bash +gh api "/repos/<owner>/<repo>/installation" --jq '.app_slug' 2>/dev/null || echo "App not detected yet" +``` + +**Step 3 — Create a queue for this repo + base branch.** +In the same flow, pick this repo and target branch `<base>`, click **Create +Queue**. *(Why: a queue is scoped to one branch — you're queuing merges into +`<base>`.)* + +**Step 4 — Adjust branch protection (3 changes).** +In GitHub → Settings → Branches → the `<base>` rule: +- **Allow the `trunk-io` bot to push to the protected branch.** *(Why: Trunk's + bot performs the actual merge; without push rights it can't land anything.)* +- **Disable "Require branches to be up to date before merging."** *(Why: Trunk + tests each PR against the others in the queue, so GitHub's own up-to-date gate + would fight it.)* +- **Exclude `trunk-merge/*` and `trunk-temp/*` from protection.** *(Why: those + are the throwaway branches Trunk tests on; protecting them blocks testing.)* + +**Step 5 — Turn on the optimizations that make "queue many, walk away" real.** +In app.trunk.io → your repo → Merge Queue → Settings, enable: +- **Optimistic Merge Queue** + **Pending Failure Depth ≥ 1** — keeps testing + later PRs while an earlier one is in "pending failure," and auto-recovers when a + later PR proves the failure was a flake. *(Why: one flaky PR doesn't stall the + whole line.)* +- **Parallel** — non-overlapping PRs test in independent lanes at the same time. + *(Why: throughput; ten unrelated PRs don't go one-at-a-time.)* +- **Batching** — lands compatible PRs together with auto-bisection on failure. + *(Why: fewer CI runs, and a bad PR doesn't eject the whole batch.)* +- **Merge Method** — pick Squash / Merge Commit / Rebase to match your repo. *(Why: + it controls what the landed commit looks like; `/land` handles all three.)* + +**Step 6 — Pick how PRs get enqueued.** +The simplest works immediately: commenting **`/trunk merge`** on a PR. `/land` +uses that by default — zero extra auth, because the GitHub App is already +installed. *(Optional upgrades: set an "enqueue by label" name in the web UI, run +`trunk login` to use the `trunk` CLI, or set `$TRUNK_API_TOKEN` for the REST +API — `/land` will prefer those when present.)* + +**Step 7 — Persist the choice so I never ask again.** +I'll write `Merge queue: trunk` into a `## Merge Configuration` section of +CLAUDE.md. *(Why: `/land` reads it and skips detection from then on.)* + +**Step 8 — Verify end-to-end.** +Open any test PR and run `/land`. You should see a **`Trunk Merge Queue +(<base>)`** check appear, move Queued → Testing → Merged, and the PR land on +`<base>` without you touching GitHub: +```bash +gh pr checks <test-pr> --json name,state | grep -i "Trunk Merge Queue" || echo "no queue check yet — recheck Steps 2-4" +``` + +Full docs: https://docs.trunk.io/merge-queue/getting-started + +Once this is done, the payoff: queue up all your ready PRs with `/land`, walk +away, and trunk lands them on `<base>` for you. + ### Step 5: Verify After writing, verify the configuration works: diff --git a/setup-deploy/SKILL.md.tmpl b/setup-deploy/SKILL.md.tmpl index 1346a711c..7325eb0bb 100644 --- a/setup-deploy/SKILL.md.tmpl +++ b/setup-deploy/SKILL.md.tmpl @@ -220,6 +220,13 @@ section if it exists, or append it. Keep it separate from Deploy Configuration s `/land` reads the `Merge queue:` line to pick its submit path. If you skip this section, `/land` falls back to live detection and asks once. +**If the user chose `trunk` and the queue isn't set up yet** (no `Trunk Merge Queue (<base>)` +check on recent PRs — check with `gh pr checks` or `gh api`), walk them through the +one-time onboarding below before writing `Merge queue: trunk`. If they chose `none` or +`github`, skip the onboarding. + +{{MERGE_QUEUE_SETUP}} + ### Step 5: Verify After writing, verify the configuration works: diff --git a/test/helpers/touchfiles.ts b/test/helpers/touchfiles.ts index ea40ed8c3..dafb9c6e0 100644 --- a/test/helpers/touchfiles.ts +++ b/test/helpers/touchfiles.ts @@ -292,12 +292,12 @@ export const E2E_TOUCHFILES: Record<string, string[]> = { // bin/gstack-merge (lib/merge.ts), so those are dependencies of every // land-and-deploy E2E — a change to the land skill or the merge helper must // re-run the composition path. - 'land-and-deploy-workflow': ['land-and-deploy/**', 'land/**', 'bin/gstack-merge', 'lib/merge.ts', 'scripts/gen-skill-docs.ts'], - 'land-and-deploy-first-run': ['land-and-deploy/**', 'land/**', 'bin/gstack-merge', 'lib/merge.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-slug'], + 'land-and-deploy-workflow': ['land-and-deploy/**', 'land/**', 'bin/gstack-merge', 'lib/merge.ts', 'scripts/resolvers/merge-queue-setup.ts', 'scripts/gen-skill-docs.ts'], + 'land-and-deploy-first-run': ['land-and-deploy/**', 'land/**', 'bin/gstack-merge', 'lib/merge.ts', 'scripts/resolvers/merge-queue-setup.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-slug'], 'land-and-deploy-review-gate': ['land-and-deploy/**', 'land/**', 'bin/gstack-review-read'], 'canary-workflow': ['canary/**', 'browse/src/**'], 'benchmark-workflow': ['benchmark/**', 'browse/src/**'], - 'setup-deploy-workflow': ['setup-deploy/**', 'bin/gstack-merge', 'lib/merge.ts', 'scripts/gen-skill-docs.ts'], + 'setup-deploy-workflow': ['setup-deploy/**', 'bin/gstack-merge', 'lib/merge.ts', 'scripts/resolvers/merge-queue-setup.ts', 'scripts/gen-skill-docs.ts'], // Sidebar agent 'sidebar-navigate': ['browse/src/server.ts', 'browse/src/sidebar-agent.ts', 'browse/src/sidebar-utils.ts', 'extension/**'], diff --git a/test/land-and-deploy-postfail.test.ts b/test/land-and-deploy-postfail.test.ts index a0599e8c5..04ac2698f 100644 --- a/test/land-and-deploy-postfail.test.ts +++ b/test/land-and-deploy-postfail.test.ts @@ -39,13 +39,15 @@ describe("PR #1620 post-failure PR-state check in /land template", () => { expect(readTmpl()).toMatch(/### 4\.2a: Post-failure PR-state check/); }); - test("post-failure check comes before the wait step", () => { + test("post-failure check comes before the landing step (4.3)", () => { const body = readTmpl(); const postfail = body.indexOf("### 4.2a: Post-failure PR-state check"); - const wait = body.indexOf("### 4.3: Wait for it to land"); + // 4.3 is the landing step (enqueue-and-return by default, or --watch). Match + // the section number, not its title, so D4's rename doesn't break the order check. + const landing = body.indexOf("### 4.3:"); expect(postfail).toBeGreaterThan(-1); - expect(wait).toBeGreaterThan(-1); - expect(postfail).toBeLessThan(wait); + expect(landing).toBeGreaterThan(-1); + expect(postfail).toBeLessThan(landing); }); test("Universal invariant + upstream gh bug references", () => {