mirror of https://github.com/garrytan/gstack.git
Merge d3f2b60779 into c43c850cae
This commit is contained in:
commit
20452aed88
43
CHANGELOG.md
43
CHANGELOG.md
|
|
@ -1,5 +1,48 @@
|
|||
# Changelog
|
||||
|
||||
## [1.56.0.0] - 2026-05-31
|
||||
|
||||
## **`/land` is a standalone skill now, and on a merge queue it's fire-and-forget: enqueue a stack of green PRs, walk away, and the queue lands them all on the base branch.**
|
||||
|
||||
There is now a `/land` skill that does exactly one thing: take a green PR and get it onto the base branch through the right regime, with the full readiness gate (reviews, tests, docs, the one irreversible-merge confirmation) intact. The headline behavior on a merge queue is what you actually want when a pile of PRs is ready: `/land` enqueues the PR and returns. It does not sit and babysit. You run `/land` on each ready PR and walk away, and the queue tests them in parallel and lands them on the base branch for you, optimistically (a later PR that already contains an earlier change can rescue it from a flaky failure). No more merge-one, wait, merge-the-next. When you do want to watch a single PR land, `/land --watch` blocks; `/land-and-deploy` always watches, because it needs the completed merge before it can deploy.
|
||||
|
||||
`/land-and-deploy` no longer carries its own copy of the merge logic; it composes `/land` and then deploys. So the merge path lives in one place, and that one place understands three worlds: no queue (`gh pr merge --squash`), GitHub's native merge queue (`gh pr merge --auto`), and the trunk.io merge queue. Trunk works the moment its GitHub App is installed, with zero extra auth, because the default submit path is a `/trunk merge` PR comment. The trunk CLI and REST API are picked up automatically when present, for queue position and priority. Never set up a merge queue before? `/land` explains what a merge queue is in plain English before it does anything, and walks you through trunk.io setup step by step the first time.
|
||||
|
||||
The trick that keeps this cheap: the submit command differs by regime, but the "did it land" signal is identical. All three end with the PR in `MERGED` state and a commit on the base branch, so one uniform poll (plus the `Trunk Merge Queue (<branch>)` status check for ejection) covers every regime. Detection reads that same status check, not the `.trunk/` directory (which the trunk linter also uses), so a repo that runs `trunk check` but not the queue is correctly read as "no queue."
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
From the diff between this branch and `origin/main`, plus `bun test`:
|
||||
|
||||
| Metric | Before | After | Δ |
|
||||
|--------|--------|-------|---|
|
||||
| Merge logic copies | 1 (inside land-and-deploy) | 1 (in /land, composed) | deduped |
|
||||
| `land-and-deploy/SKILL.md` | 1860 lines | 1568 lines | −292 |
|
||||
| Merge regimes supported | 1 (GitHub auto-merge) | 3 (none / github / trunk) | +2 |
|
||||
| Deterministic merge tests | 0 | 41 (30 unit + 11 CLI) | +41 |
|
||||
| New auth to use trunk.io | n/a | 0 (gh comment path) | none |
|
||||
|
||||
The merge SHA the deploy half needs for a revert now travels as an explicit, validated `last-land.json` handoff (right PR, right repo, recent, non-null SHA) instead of hoping the agent carries it across the skill boundary. Rebase-merge repos (where the merge commit can be null) are handled: landing is confirmed by `state == MERGED` AND the commit actually being on the base branch.
|
||||
|
||||
### What this means for you
|
||||
|
||||
If you only want to merge, run `/land` and stop. Got ten PRs green and ready? Run `/land` on each and walk away; the queue lands them all without you sequencing merges by hand. If you want merge plus deploy plus canary, run `/land-and-deploy` exactly like before; it just routes the merge through `/land` now (in watch mode, since it deploys the result). On a trunk.io repo, gstack stops fighting the queue and uses it: it enqueues, points you at the queue's own status check and dashboard, and tells you plainly if the queue ejects the PR. Set the regime once in CLAUDE.md (`/setup-deploy` writes it) or let `/land` detect it and offer to set trunk.io up for you. Non-trunk users pay nothing for any of this.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
- **`/land` skill**: lands a PR standalone: pre-flight, CI wait, VERSION-drift check, the pre-merge readiness gate (with a `--fast` flag that skips soft-warning prompts but never a real blocker), and a regime-aware merge. Writes a `last-land.json` handoff and prints a `LANDED:` line on success.
|
||||
- **Enqueue-and-return is the default on a merge queue.** `/land` hands the PR to the queue and returns, so you can `/land` a stack of ready PRs and walk away while the queue lands them in parallel. `--watch` opts into blocking until a single PR lands (and is what `/land-and-deploy` uses). A no-queue repo merges synchronously, as before.
|
||||
- **First-time merge-queue onboarding.** When a repo has no queue configured, `/land` offers to set trunk.io up and hand-holds the whole thing: install the GitHub App, create the queue, the three branch-protection changes, and the optimizations (optimistic merge, parallel, batching) that make "queue many, walk away" work. It also explains, in plain English, what a merge queue is before doing anything. The walkthrough lives in one shared place, used by both `/land` and `/setup-deploy`.
|
||||
- **Merge-queue support for three regimes**: none, GitHub native merge queue, and trunk.io. Trunk submit is comment-first (`gh pr comment "/trunk merge"`, zero new auth), with the trunk CLI and REST API (`$TRUNK_API_TOKEN`) as automatic upgrades for priority and queue position.
|
||||
- **`bin/gstack-merge`**: a small, unit-tested helper (`detect` / `submit` / `wait` / `write-state` / `read-state`) backed by `lib/merge.ts`. The same `detect` powers `/land`, `/land-and-deploy`'s dry-run, and `/setup-deploy`, so they never disagree.
|
||||
- **`## Merge Configuration` in CLAUDE.md**: `/setup-deploy` now records `Merge queue: none|github|trunk` separately from deploy settings, so `/land` reads merge config with zero deploy coupling.
|
||||
|
||||
#### Changed
|
||||
- **`/land-and-deploy` composes `/land`** instead of carrying its own merge steps. It consumes the validated handoff, verifies the merge SHA is really on the base branch before deploying, and goes revert-PR-first on merge-queue / protected branches.
|
||||
|
||||
#### For contributors
|
||||
- `lib/merge.ts` holds the pure regime logic (detection precedence, submit planning, landing classification, handoff schema + validation); `test/gstack-merge.test.ts` (30) and `test/gstack-merge-cli.test.ts` (11) pin it. A generated-doc scrub test fails CI if `/land`'s SKILL.md ever grows deploy/canary machinery. The merge SHA → revert handoff and the never-blind-retry invariant (cli/cli#3442, cli/cli#13380) moved into `/land` with their tests.
|
||||
## [1.55.1.0] - 2026-06-02
|
||||
|
||||
## **Telemetry now tells you exactly what it records and where it stays. The project-slug helper hands the shell a safe identifier on every path.**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,524 @@
|
|||
#!/usr/bin/env bun
|
||||
// gstack-merge — regime-aware merge driver for /land and /land-and-deploy.
|
||||
//
|
||||
// Owns the four risky merge operations as a thin CLI over the pure logic in
|
||||
// lib/merge.ts (which is unit-tested). Supports three regimes: none, GitHub
|
||||
// native merge queue, and trunk.io merge queue.
|
||||
//
|
||||
// Subcommands:
|
||||
// detect [--base B] [--pr N] [--json]
|
||||
// submit --regime R --pr N [--base B] [--priority P]
|
||||
// confirm-enqueue --regime R --pr N --base B [--slug S] [--enqueue-timeout S]
|
||||
// wait --pr N --base B [--regime R] [--timeout S] [--interval S]
|
||||
// [--enqueue-timeout S] [--once]
|
||||
// write-state --pr N --base B --slug SLUG [--regime R] [--sha-timeout S]
|
||||
// read-state --slug SLUG --pr N --repo OWNER/NAME [--max-age-ms N] [--json]
|
||||
//
|
||||
// `submit` enqueues. For a queue regime the merge then happens asynchronously
|
||||
// (trunk/GitHub land it), so /land's DEFAULT is submit + confirm-enqueue +
|
||||
// return — the user can queue many PRs and walk away. `--watch` swaps
|
||||
// confirm-enqueue for the blocking `wait` (poll until landed), which is also
|
||||
// what /land-and-deploy uses (it needs the completed merge to deploy).
|
||||
//
|
||||
// Contract: detect/confirm-enqueue/wait/read-state never mutate git/PR state.
|
||||
// submit and write-state are the only state-touching subcommands.
|
||||
// confirm-enqueue writes last-enqueue.json (a queued marker, no merge SHA);
|
||||
// write-state writes last-land.json (a confirmed merge with a SHA).
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
detectRegime,
|
||||
planSubmit,
|
||||
classifyLand,
|
||||
buildLandState,
|
||||
validateConsume,
|
||||
isTrunkQueueCheck,
|
||||
trunkQueueCheckName,
|
||||
type Regime,
|
||||
type PrCheck,
|
||||
} from '../lib/merge';
|
||||
|
||||
// --- arg parsing --------------------------------------------------------
|
||||
|
||||
function parseArgs(argv: string[]): { cmd: string; flags: Record<string, string>; bools: Set<string> } {
|
||||
const cmd = argv[0] || '';
|
||||
const flags: Record<string, string> = {};
|
||||
const bools = new Set<string>();
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
bools.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cmd, flags, bools };
|
||||
}
|
||||
|
||||
// --- shell helpers ------------------------------------------------------
|
||||
|
||||
function run(cmd: string, args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf-8', timeout: 60000 });
|
||||
return { code: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() };
|
||||
}
|
||||
|
||||
function ghJson<T>(args: string[]): T | null {
|
||||
const r = run('gh', args);
|
||||
if (!r.stdout) return null;
|
||||
try {
|
||||
return JSON.parse(r.stdout) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function which(bin: string): boolean {
|
||||
return run('command', ['-v', bin]).code === 0 || run('sh', ['-c', `command -v ${bin}`]).code === 0;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// --- data gatherers -----------------------------------------------------
|
||||
|
||||
interface PrView {
|
||||
number?: number;
|
||||
state?: string;
|
||||
mergeCommit?: { oid?: string } | null;
|
||||
headRefOid?: string;
|
||||
headRefName?: string;
|
||||
baseRefName?: string;
|
||||
autoMergeRequest?: unknown;
|
||||
}
|
||||
|
||||
function prView(pr: string | undefined): PrView | null {
|
||||
const args = ['pr', 'view'];
|
||||
if (pr) args.push(pr);
|
||||
args.push('--json', 'number,state,mergeCommit,headRefOid,headRefName,baseRefName,autoMergeRequest');
|
||||
return ghJson<PrView>(args);
|
||||
}
|
||||
|
||||
function prChecks(pr: string | undefined): PrCheck[] {
|
||||
const args = ['pr', 'checks'];
|
||||
if (pr) args.push(pr);
|
||||
// gh pr checks exits non-zero when checks are failing/pending; we still want the JSON.
|
||||
args.push('--json', 'name,state,bucket');
|
||||
const r = run('gh', args);
|
||||
if (!r.stdout) return [];
|
||||
try {
|
||||
return JSON.parse(r.stdout) as PrCheck[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function repoSlug(): string {
|
||||
const r = ghJson<{ nameWithOwner?: string }>(['repo', 'view', '--json', 'nameWithOwner']);
|
||||
return r?.nameWithOwner || '';
|
||||
}
|
||||
|
||||
function readTrunkYaml(): string | null {
|
||||
const p = join(process.cwd(), '.trunk', 'trunk.yaml');
|
||||
if (existsSync(p)) {
|
||||
try { return readFileSync(p, 'utf-8'); } catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse "Merge queue: X" from the ## Merge Configuration section of CLAUDE.md. */
|
||||
function readConfigRegime(): string | null {
|
||||
const p = join(process.cwd(), 'CLAUDE.md');
|
||||
if (!existsSync(p)) return null;
|
||||
let body: string;
|
||||
try { body = readFileSync(p, 'utf-8'); } catch { return null; }
|
||||
const m = body.match(/##\s*Merge Configuration[\s\S]*?(?=\n##\s|\n#\s|$)/);
|
||||
if (!m) return null;
|
||||
const line = m[0].match(/Merge queue:\s*([a-zA-Z]+)/);
|
||||
return line ? line[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** Best-effort GitHub-native merge-queue detection via the GraphQL API. */
|
||||
function githubMergeQueueEnabled(slug: string, base: string): boolean {
|
||||
if (!slug.includes('/')) return false;
|
||||
const [owner, name] = slug.split('/');
|
||||
const q = `query($o:String!,$n:String!,$b:String!){repository(owner:$o,name:$n){mergeQueue(branch:$b){id}}}`;
|
||||
const r = run('gh', ['api', 'graphql', '-f', `query=${q}`, '-f', `o=${owner}`, '-f', `n=${name}`, '-f', `b=${base}`]);
|
||||
if (r.code !== 0 || !r.stdout) return false;
|
||||
try {
|
||||
const j = JSON.parse(r.stdout);
|
||||
return !!j?.data?.repository?.mergeQueue?.id;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Does origin/<base> contain the head commit? (fetches first). */
|
||||
function baseContainsHead(base: string, headRefOid: string): boolean {
|
||||
if (!headRefOid) return false;
|
||||
run('git', ['fetch', 'origin', base]);
|
||||
return run('git', ['merge-base', '--is-ancestor', headRefOid, `origin/${base}`]).code === 0;
|
||||
}
|
||||
|
||||
function findQueueCheck(checks: PrCheck[], base: string): PrCheck | null {
|
||||
return (
|
||||
checks.find((c) => c.name === trunkQueueCheckName(base)) ||
|
||||
checks.find((c) => isTrunkQueueCheck(c.name)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// --- subcommands --------------------------------------------------------
|
||||
|
||||
async function cmdDetect(flags: Record<string, string>, bools: Set<string>): Promise<number> {
|
||||
const pr = flags.pr;
|
||||
const slug = repoSlug();
|
||||
const base = flags.base || prView(pr)?.baseRefName || 'main';
|
||||
const checks = prChecks(pr);
|
||||
const result = detectRegime({
|
||||
base,
|
||||
checks,
|
||||
trunkYaml: readTrunkYaml(),
|
||||
configRegime: readConfigRegime(),
|
||||
githubMergeQueue: githubMergeQueueEnabled(slug, base),
|
||||
});
|
||||
if (bools.has('json')) {
|
||||
process.stdout.write(JSON.stringify({ ...result, base }) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`MERGE_REGIME=${result.regime}\nMERGE_REGIME_SOURCE=${result.source}\nBASE=${base}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdSubmit(flags: Record<string, string>): number {
|
||||
const pr = flags.pr;
|
||||
const regime = (flags.regime || 'none') as Regime;
|
||||
if (!pr) { process.stderr.write('submit: --pr is required\n'); return 2; }
|
||||
|
||||
const plan = planSubmit(regime, Number(pr), {
|
||||
trunkCliAvailable: which('trunk'),
|
||||
trunkToken: !!process.env.TRUNK_API_TOKEN,
|
||||
priority: flags.priority,
|
||||
});
|
||||
|
||||
for (const cand of plan.candidates) {
|
||||
process.stdout.write(`→ ${cand.desc}: ${cand.cmd} ${cand.args.join(' ')}\n`);
|
||||
let r: { code: number; stdout: string; stderr: string };
|
||||
if (cand.cmd === 'trunk-rest') {
|
||||
r = submitViaRest(Number(pr), flags.base || '', flags.priority);
|
||||
} else {
|
||||
r = run(cand.cmd, cand.args);
|
||||
}
|
||||
if (r.code === 0) {
|
||||
process.stdout.write(`SUBMIT_OK=${regime}\nSUBMIT_VIA=${cand.desc}\n`);
|
||||
if (r.stdout) process.stdout.write(r.stdout + '\n');
|
||||
return 0;
|
||||
}
|
||||
process.stderr.write(` (failed: ${r.stderr || r.stdout || 'exit ' + r.code})\n`);
|
||||
}
|
||||
process.stderr.write(`SUBMIT_FAILED=${regime} — no candidate succeeded\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
function submitViaRest(pr: number, base: string, priority?: string): { code: number; stdout: string; stderr: string } {
|
||||
const token = process.env.TRUNK_API_TOKEN;
|
||||
if (!token) return { code: 1, stdout: '', stderr: 'TRUNK_API_TOKEN not set' };
|
||||
const slug = repoSlug();
|
||||
const [owner, name] = slug.split('/');
|
||||
const payload = JSON.stringify({
|
||||
repo: { host: 'github.com', owner, name },
|
||||
pr: { number: pr },
|
||||
targetBranch: base || undefined,
|
||||
...(priority ? { priority } : {}),
|
||||
});
|
||||
return run('curl', [
|
||||
'-sS', '-X', 'POST', 'https://api.trunk.io/v1/submitPullRequest',
|
||||
'-H', `x-api-token: ${token}`,
|
||||
'-H', 'Content-Type: application/json',
|
||||
'-d', payload,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Write last-enqueue.json — a queued marker (no merge SHA yet). */
|
||||
function writeEnqueueState(slug: string, pr: number, repo: string, regime: Regime, base: string): string {
|
||||
const dir = join(homedir(), '.gstack', 'projects', slug);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const dest = join(dir, 'last-enqueue.json');
|
||||
const tmp = `${dest}.${process.pid}.tmp`;
|
||||
const body = { schema_version: 1, pr, repo, regime, base, queued: true, ts: new Date().toISOString() };
|
||||
writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n', { mode: 0o600 });
|
||||
renameSync(tmp, dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a queue regime actually picked the PR up, then RETURN (don't wait for
|
||||
* it to land). This is the default for /land on a queue regime — the whole
|
||||
* point of a merge queue is that you don't babysit. Trunk lands it (optimistic
|
||||
* merge / parallel / batching) on its own.
|
||||
*
|
||||
* Confirmed when: PR already MERGED (fast direct-merge-to-main), OR the
|
||||
* "Trunk Merge Queue (<base>)" check appears (trunk), OR GitHub auto-merge is
|
||||
* enabled (github). Times out for trunk if a posted "/trunk merge" was inert
|
||||
* (App not installed / "GitHub commands" off).
|
||||
*/
|
||||
async function cmdConfirmEnqueue(flags: Record<string, string>): Promise<number> {
|
||||
const pr = flags.pr;
|
||||
const base = flags.base;
|
||||
if (!pr || !base) { process.stderr.write('confirm-enqueue: --pr and --base are required\n'); return 2; }
|
||||
const regime = (flags.regime || 'none') as Regime;
|
||||
const enqueueMs = (Number(flags['enqueue-timeout']) || 120) * 1000;
|
||||
const intervalMs = (Number(flags.interval) || 15) * 1000;
|
||||
|
||||
if (regime === 'none') {
|
||||
// Direct merge is synchronous — there is nothing to enqueue.
|
||||
const v = prView(pr) || {};
|
||||
process.stdout.write(`ENQUEUE_NA=none\nPR_STATE=${(v.state || 'OPEN').toUpperCase()}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let confirmed = false;
|
||||
let how = '';
|
||||
while (Date.now() - start < enqueueMs) {
|
||||
const v = prView(pr) || {};
|
||||
if ((v.state || '').toUpperCase() === 'MERGED') { confirmed = true; how = 'already merged'; break; }
|
||||
if (regime === 'trunk' && findQueueCheck(prChecks(pr), base)) {
|
||||
confirmed = true; how = `${trunkQueueCheckName(base)} check present`; break;
|
||||
}
|
||||
if (regime === 'github' && v.autoMergeRequest != null) {
|
||||
confirmed = true; how = 'GitHub auto-merge enabled'; break;
|
||||
}
|
||||
await sleep(Math.min(intervalMs, 15000));
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
if (regime === 'trunk') {
|
||||
process.stderr.write(
|
||||
'TRUNK_ENQUEUE_TIMEOUT — posted "/trunk merge" but no "' + trunkQueueCheckName(base) +
|
||||
'" check appeared. Is the Trunk GitHub App installed and "GitHub commands" enabled?\n',
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'ENQUEUE_UNCONFIRMED — GitHub auto-merge is not enabled on the PR after submit. ' +
|
||||
'Check the repo merge-queue / auto-merge settings.\n',
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
let stateFile = '';
|
||||
if (flags.slug) stateFile = writeEnqueueState(flags.slug, Number(pr), repoSlug(), regime, base);
|
||||
|
||||
process.stdout.write(`ENQUEUED=${regime}\nENQUEUE_HOW=${how}\n`);
|
||||
if (regime === 'trunk') {
|
||||
process.stdout.write(`WATCH_CHECK=${trunkQueueCheckName(base)}\nWATCH_DASHBOARD=https://app.trunk.io\n`);
|
||||
} else {
|
||||
process.stdout.write('WATCH_CHECK=GitHub merge queue (on the PR)\n');
|
||||
}
|
||||
if (stateFile) process.stdout.write(`ENQUEUE_FILE=${stateFile}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function cmdWait(flags: Record<string, string>): Promise<number> {
|
||||
const pr = flags.pr;
|
||||
const base = flags.base;
|
||||
if (!pr || !base) { process.stderr.write('wait: --pr and --base are required\n'); return 2; }
|
||||
const regime = (flags.regime || 'none') as Regime;
|
||||
const intervalMs = (Number(flags.interval) || 30) * 1000;
|
||||
const timeoutMs = (Number(flags.timeout) || 1800) * 1000; // 30 min
|
||||
const enqueueMs = (Number(flags['enqueue-timeout']) || 120) * 1000;
|
||||
const once = flags.once !== undefined || process.argv.includes('--once');
|
||||
|
||||
const classifyNow = () => {
|
||||
const v = prView(pr) || {};
|
||||
const checks = prChecks(pr);
|
||||
const queueCheck = findQueueCheck(checks, base);
|
||||
const oid = v.mergeCommit?.oid || null;
|
||||
return classifyLand({
|
||||
state: v.state || 'OPEN',
|
||||
mergeCommitOid: oid,
|
||||
baseContainsHead: oid ? true : baseContainsHead(base, v.headRefOid || ''),
|
||||
queueCheck,
|
||||
autoMergeEnabled: v.autoMergeRequest != null,
|
||||
});
|
||||
};
|
||||
|
||||
if (once) {
|
||||
const c = classifyNow();
|
||||
process.stdout.write(`LAND_STATUS=${c.status}\nLAND_REASON=${c.reason}\n`);
|
||||
return c.status === 'landed' ? 0 : c.status === 'pending' ? 0 : 1;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
// Trunk phase 1: confirm the comment actually enqueued (the queue check
|
||||
// appears) — a posted "/trunk merge" comment is silently inert if the
|
||||
// GitHub App isn't installed or "GitHub commands" is off.
|
||||
if (regime === 'trunk') {
|
||||
let enqueued = false;
|
||||
while (Date.now() - start < enqueueMs) {
|
||||
const v = prView(pr) || {};
|
||||
if ((v.state || '').toUpperCase() === 'MERGED') { enqueued = true; break; }
|
||||
if (findQueueCheck(prChecks(pr), base)) { enqueued = true; break; }
|
||||
await sleep(Math.min(intervalMs, 15000));
|
||||
}
|
||||
if (!enqueued) {
|
||||
process.stderr.write(
|
||||
'TRUNK_ENQUEUE_TIMEOUT — posted "/trunk merge" but no "' + trunkQueueCheckName(base) +
|
||||
'" check appeared. Is the Trunk GitHub App installed and "GitHub commands" enabled?\n',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write('Trunk picked up the PR — waiting for the queue to finish.\n');
|
||||
}
|
||||
|
||||
// Phase 2: resolution.
|
||||
let lastNarrate = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const c = classifyNow();
|
||||
if (c.status === 'landed') {
|
||||
process.stdout.write(`LAND_STATUS=landed\nLAND_REASON=${c.reason}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (c.status === 'ejected') {
|
||||
process.stderr.write(`LAND_STATUS=ejected\nLAND_REASON=${c.reason}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (c.status === 'closed') {
|
||||
process.stderr.write(`LAND_STATUS=closed\nLAND_REASON=${c.reason}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (Date.now() - lastNarrate > 120000) {
|
||||
const mins = Math.round((Date.now() - start) / 60000);
|
||||
process.stdout.write(`Still waiting to land... (${mins}m so far)\n`);
|
||||
lastNarrate = Date.now();
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
process.stderr.write(`LAND_STATUS=timeout\nLAND_REASON=did not land within ${Math.round(timeoutMs / 60000)}m\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function cmdWriteState(flags: Record<string, string>): Promise<number> {
|
||||
const pr = flags.pr;
|
||||
const base = flags.base;
|
||||
const slug = flags.slug;
|
||||
if (!pr || !base || !slug) { process.stderr.write('write-state: --pr, --base, --slug are required\n'); return 2; }
|
||||
const regime = (flags.regime || 'none') as Regime;
|
||||
const shaTimeoutMs = (Number(flags['sha-timeout']) || 90) * 1000;
|
||||
|
||||
// Poll until MERGED with a non-null merge SHA (the SHA can lag the state
|
||||
// flip on squash/queue merges). Fall back to the base tip only in the
|
||||
// genuine rebase-null case (MERGED + base contains head but oid stays null).
|
||||
const start = Date.now();
|
||||
let v: PrView | null = null;
|
||||
let oid: string | null = null;
|
||||
while (Date.now() - start < shaTimeoutMs) {
|
||||
v = prView(pr);
|
||||
const state = (v?.state || '').toUpperCase();
|
||||
oid = v?.mergeCommit?.oid || null;
|
||||
if (state === 'MERGED' && oid) break;
|
||||
if (state === 'CLOSED') { process.stderr.write('write-state: PR is CLOSED, not merged\n'); return 1; }
|
||||
await sleep(5000);
|
||||
}
|
||||
v = v || prView(pr) || {};
|
||||
const state = (v.state || '').toUpperCase();
|
||||
const headRefOid = v.headRefOid || '';
|
||||
const contains = baseContainsHead(base, headRefOid);
|
||||
|
||||
if (state !== 'MERGED' || !(oid || contains)) {
|
||||
process.stderr.write('write-state: landing not confirmed (PR not MERGED on base) — refusing to write handoff\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
let sha = oid || '';
|
||||
let shaNote = '';
|
||||
if (!sha) {
|
||||
// rebase-null edge: use the base tip as the best available revert target.
|
||||
const tip = run('git', ['rev-parse', `origin/${base}`]).stdout;
|
||||
sha = tip;
|
||||
shaNote = ' (no mergeCommit.oid — using base tip; rebase-merge repo)';
|
||||
}
|
||||
|
||||
const state2 = buildLandState({
|
||||
pr: Number(pr),
|
||||
sha,
|
||||
headRefOid,
|
||||
base,
|
||||
head_branch: v.headRefName || '',
|
||||
repo: repoSlug(),
|
||||
regime,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const dir = join(homedir(), '.gstack', 'projects', slug);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const dest = join(dir, 'last-land.json');
|
||||
const tmp = `${dest}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(state2, null, 2) + '\n', { mode: 0o600 });
|
||||
renameSync(tmp, dest);
|
||||
|
||||
process.stdout.write(`LANDED: pr=#${pr} sha=${sha} regime=${regime} base=${base}${shaNote}\n`);
|
||||
process.stdout.write(`HANDOFF_FILE=${dest}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdReadState(flags: Record<string, string>, bools: Set<string>): number {
|
||||
const slug = flags.slug;
|
||||
const pr = flags.pr;
|
||||
const repo = flags.repo;
|
||||
if (!slug || !pr || !repo) { process.stderr.write('read-state: --slug, --pr, --repo are required\n'); return 2; }
|
||||
const dest = join(homedir(), '.gstack', 'projects', slug, 'last-land.json');
|
||||
let state: any = null;
|
||||
if (existsSync(dest)) {
|
||||
try { state = JSON.parse(readFileSync(dest, 'utf-8')); } catch { state = null; }
|
||||
}
|
||||
const v = validateConsume(state, {
|
||||
pr: Number(pr),
|
||||
repo,
|
||||
maxAgeMs: flags['max-age-ms'] ? Number(flags['max-age-ms']) : undefined,
|
||||
}, Date.now());
|
||||
if (!v.ok) {
|
||||
process.stderr.write(`READ_STATE_INVALID=${v.reason}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (bools.has('json')) {
|
||||
process.stdout.write(JSON.stringify(state) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`LAND_SHA=${state.sha}\nLAND_BASE=${state.base}\nLAND_REGIME=${state.regime}\nLAND_HEAD=${state.head_branch}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- main ---------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const { cmd, flags, bools } = parseArgs(process.argv.slice(2));
|
||||
switch (cmd) {
|
||||
case 'detect': return cmdDetect(flags, bools);
|
||||
case 'submit': return cmdSubmit(flags);
|
||||
case 'confirm-enqueue': return cmdConfirmEnqueue(flags);
|
||||
case 'wait': return cmdWait(flags);
|
||||
case 'write-state': return cmdWriteState(flags);
|
||||
case 'read-state': return cmdReadState(flags, bools);
|
||||
default:
|
||||
process.stderr.write('usage: gstack-merge <detect|submit|confirm-enqueue|wait|write-state|read-state> [flags]\n');
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().then((code) => process.exit(code)).catch((err) => {
|
||||
process.stderr.write(`gstack-merge: ${err?.message || err}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ Conventions:
|
|||
- [/ios-fix](ios-fix/SKILL.md): Autonomous iOS bug fixer.
|
||||
- [/ios-qa](ios-qa/SKILL.md): Live-device iOS QA for SwiftUI apps.
|
||||
- [/ios-sync](ios-sync/SKILL.md): Regenerate the iOS debug bridge against the latest upstream gstack templates.
|
||||
- [/land](land/SKILL.md): Land a PR through the right merge regime: pre-flight, CI wait, VERSION-drift check, pre-merge readiness gate, then merge via no-queue, GitHub native merge queue, or trunk.io merge queue.
|
||||
- [/land-and-deploy](land-and-deploy/SKILL.md): Land and deploy workflow.
|
||||
- [/landing-report](landing-report/SKILL.md): Read-only queue dashboard for workspace-aware ship.
|
||||
- [/learn](learn/SKILL.md): Manage project learnings.
|
||||
|
|
|
|||
|
|
@ -836,16 +836,17 @@ readiness first.
|
|||
|
||||
**Always stop for:**
|
||||
- **First-run dry-run validation (Step 1.5)** — shows deploy infrastructure and confirms setup
|
||||
- **Pre-merge readiness gate (Step 3.5)** — reviews, tests, docs check before merge
|
||||
- **Pre-merge readiness gate** — owned by `/land` (its Step 3.5: reviews, tests, docs, then the single irreversible-merge confirmation)
|
||||
- GitHub CLI not authenticated
|
||||
- No PR found for this branch
|
||||
- CI failures or merge conflicts
|
||||
- Permission denied on merge
|
||||
- CI failures or merge conflicts (surfaced by `/land`)
|
||||
- Permission denied on merge / merge-queue ejection (surfaced by `/land`)
|
||||
- Landing could not be confirmed (no merge SHA in the handoff)
|
||||
- Deploy workflow failure (offer revert)
|
||||
- Production health issues detected by canary (offer revert)
|
||||
|
||||
**Never stop for:**
|
||||
- Choosing merge method (auto-detect from repo settings)
|
||||
- Choosing the merge regime (`/land` resolves it: config → detect → ask once → persist)
|
||||
- Timeout warnings (warn and continue gracefully)
|
||||
|
||||
## Voice & Tone
|
||||
|
|
@ -883,9 +884,16 @@ gh pr view --json number,state,title,url,mergeStateStatus,mergeable,baseRefName,
|
|||
|
||||
5. Validate the PR state:
|
||||
- If no PR exists: **STOP.** "No PR found for this branch. Run `/ship` first to create a PR, then come back here to land and deploy it."
|
||||
- If `state` is `MERGED`: "This PR is already merged — nothing to deploy. If you need to verify the deploy, run `/canary <url>` instead."
|
||||
- If `state` is `CLOSED`: "This PR was closed without merging. Reopen it on GitHub first, then try again."
|
||||
- If `state` is `OPEN`: continue.
|
||||
- If `state` is `OPEN`: continue to Step 1.5.
|
||||
- If `state` is `MERGED` (already-landed re-run — e.g. you deployed to staging only earlier, or a previous run merged then stopped): do NOT re-invoke `/land`. Try to consume the landing record instead:
|
||||
```bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)
|
||||
~/.claude/skills/gstack/bin/gstack-merge read-state --slug "$SLUG" --pr <NNN> --repo "$REPO"
|
||||
```
|
||||
- If it prints `LAND_SHA=...` (valid handoff for this PR): tell the user "This PR already landed — skipping the merge and going straight to deploy verification." Capture `LAND_SHA`/`LAND_BASE`/`LAND_REGIME`/`LAND_HEAD`, then skip Steps 1.5 and 2 and go to **Step 3** (post-merge CI auto-deploy detection).
|
||||
- If it prints `READ_STATE_INVALID=...` (no/stale/foreign handoff): **STOP.** "This PR is already merged but I have no landing record for it, so I can't safely match the deploy or offer a revert. Run `/canary <url>` to verify the live site, or revert manually if needed."
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -991,8 +999,14 @@ gh auth status 2>&1 | head -3
|
|||
|
||||
# Test production URL reachability
|
||||
# curl -sf {production-url} -o /dev/null -w "%{http_code}" 2>/dev/null
|
||||
|
||||
# Detect the merge regime with the SAME helper /land will use (so the dry-run
|
||||
# table tells the truth — no separate detection logic that could disagree).
|
||||
~/.claude/skills/gstack/bin/gstack-merge detect --json 2>/dev/null
|
||||
```
|
||||
|
||||
Parse the `gstack-merge detect` output (`{"regime":"none|github|trunk","source":"..."}`) and use it for the MERGE QUEUE / MERGE METHOD rows below. This is informational only — nothing is merged here.
|
||||
|
||||
Run whichever commands are relevant based on the detected platform. Build the results into this table:
|
||||
|
||||
```
|
||||
|
|
@ -1022,8 +1036,9 @@ Run whichever commands are relevant based on the detected platform. Build the re
|
|||
║ 4. {Wait for deploy workflow / Wait 60s / Skip} ║
|
||||
║ 5. {Run canary verification / Skip (no URL)} ║
|
||||
║ ║
|
||||
║ MERGE METHOD: {squash/merge/rebase} (from repo settings) ║
|
||||
║ MERGE QUEUE: {detected / not detected} ║
|
||||
║ MERGE REGIME: {none / github / trunk} (from {source}) ║
|
||||
║ MERGE QUEUE: {none / GitHub native / trunk.io} ║
|
||||
║ MERGED BY: /land (Step 2) — readiness gate + merge ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
|
|
@ -1107,414 +1122,98 @@ Continue to Step 2.
|
|||
|
||||
---
|
||||
|
||||
## Step 2: Pre-merge checks
|
||||
## Step 2: Land the PR (compose /land)
|
||||
|
||||
Tell the user: "Checking CI status and merge readiness..."
|
||||
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.
|
||||
|
||||
Check CI status and merge readiness:
|
||||
**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.
|
||||
|
||||
**If unreadable:** Skip with "Could not load /land — skipping." and continue.
|
||||
|
||||
Follow its instructions from top to bottom, **skipping these sections** (already handled by the parent skill):
|
||||
- Preamble (run first)
|
||||
- AskUserQuestion Format
|
||||
- Completeness Principle — Boil the Lake
|
||||
- Search Before Building
|
||||
- Contributor Mode
|
||||
- Completion Status Protocol
|
||||
- Telemetry (run last)
|
||||
- Step 0: Detect platform and base branch
|
||||
- Review Readiness Dashboard
|
||||
- Plan File Review Report
|
||||
- Prerequisite Skill Offer
|
||||
- Plan Status Footer
|
||||
|
||||
Execute every other section at full depth. When the loaded skill's instructions are complete, continue with the next step below.
|
||||
|
||||
`/land`'s readiness gate (its Step 3.5) owns the single irreversible-merge
|
||||
confirmation. The dry-run above was informational only — do NOT add a second merge
|
||||
confirmation here.
|
||||
|
||||
### 2.1: Consume the landing handoff
|
||||
|
||||
`/land` writes a `last-land.json` handoff and prints a `LANDED:` line. Skill composition
|
||||
does not return structured data across the boundary, so read the file explicitly and
|
||||
validate it before touching any deploy or revert path:
|
||||
|
||||
```bash
|
||||
gh pr checks --json name,state,status,conclusion
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)
|
||||
~/.claude/skills/gstack/bin/gstack-merge read-state --slug "$SLUG" --pr <NNN> --repo "$REPO"
|
||||
```
|
||||
|
||||
Parse the output:
|
||||
1. If any required checks are **FAILING**: **STOP.** "CI is failing on this PR. Here are the failing checks: {list}. Fix these before deploying — I won't merge code that hasn't passed CI."
|
||||
2. If required checks are **PENDING**: Tell the user "CI is still running. I'll wait for it to finish." Proceed to Step 3.
|
||||
3. If all checks pass (or no required checks): Tell the user "CI passed." Skip Step 3, go to Step 4.
|
||||
- **`READ_STATE_INVALID=...`** (no / stale / wrong-PR / wrong-repo handoff): **STOP.**
|
||||
"I couldn't confirm this PR actually landed (no valid landing record). I won't deploy
|
||||
off an unconfirmed merge. Re-run `/land` and check why it didn't complete."
|
||||
- **`LAND_SHA=... LAND_BASE=... LAND_REGIME=... LAND_HEAD=...`**: capture these. `LAND_SHA`
|
||||
is the merge commit on the base branch — every deploy-workflow match and any revert
|
||||
uses it.
|
||||
|
||||
### 2.2: Verify the SHA is really on the base branch (H2)
|
||||
|
||||
Don't trust metadata alone — confirm the commit actually landed on the base:
|
||||
|
||||
Also check for merge conflicts:
|
||||
```bash
|
||||
gh pr view --json mergeable -q .mergeable
|
||||
git fetch origin <base>
|
||||
git merge-base --is-ancestor <LAND_SHA> origin/<base> && echo "ON_BASE" || echo "NOT_ON_BASE"
|
||||
```
|
||||
If `CONFLICTING`: **STOP.** "This PR has merge conflicts with the base branch. Resolve the conflicts and push, then run `/land-and-deploy` again."
|
||||
|
||||
If `NOT_ON_BASE`: **STOP.** "GitHub reports the PR merged, but `<LAND_SHA>` isn't on
|
||||
`origin/<base>` yet. Wait a moment and re-run `/land-and-deploy`, or check the repo —
|
||||
I won't deploy or offer a revert against a commit I can't see on the base branch."
|
||||
|
||||
If `ON_BASE`: the PR has truly landed. Continue to Step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Wait for CI (if pending)
|
||||
## Step 3: Post-merge CI auto-deploy detection
|
||||
|
||||
If required checks are still pending, wait for them to complete. Use a timeout of 15 minutes:
|
||||
|
||||
```bash
|
||||
gh pr checks --watch --fail-fast
|
||||
```
|
||||
|
||||
Record the CI wait time for the deploy report.
|
||||
|
||||
If CI passes within the timeout: Tell the user "CI passed after {duration}. Moving to readiness checks." Continue to Step 4.
|
||||
If CI fails: **STOP.** "CI failed. Here's what broke: {failures}. This needs to pass before I can merge."
|
||||
If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that's unusual. Check the GitHub Actions tab to see if something is stuck."
|
||||
|
||||
---
|
||||
|
||||
## Step 3.4: VERSION drift detection (workspace-aware ship)
|
||||
|
||||
Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
|
||||
# Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection)
|
||||
# We don't need the exact original level — we just need "a level" that passes to the util.
|
||||
# If the minor digit advanced, call it minor; patch digit, patch; etc. If base > branch, skip (not ours to land).
|
||||
# For simplicity: use "patch" as a conservative default; util handles collision-past regardless of input level.
|
||||
QUEUE_JSON=$(bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
1. If `OFFLINE=true` or the util fails: print `⚠ VERSION drift check unavailable (util offline) — proceeding with PR version v<BRANCH_VERSION>`. Continue to Step 3.5. CI's version-gate job is the backstop.
|
||||
|
||||
2. If `BRANCH_VERSION` is already `>=` than `NEXT_SLOT`: no drift (or our PR is ahead of the queue). Continue.
|
||||
|
||||
3. If drift is detected (a PR landed ahead of us and `BRANCH_VERSION < NEXT_SLOT`): **STOP** and print exactly:
|
||||
```
|
||||
⚠ VERSION drift detected.
|
||||
This PR claims: v<BRANCH_VERSION>
|
||||
Next free slot: v<NEXT_SLOT> (queue moved since last /ship)
|
||||
|
||||
Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED
|
||||
branch will detect the drift and rewrite VERSION + CHANGELOG header + PR title
|
||||
atomically. Do NOT merge from here — the landed PR would overwrite the other
|
||||
branch's CHANGELOG entry or land with a duplicate version header.
|
||||
```
|
||||
|
||||
Exit non-zero. Do NOT auto-bump from `/land-and-deploy` — rerunning `/ship` is the clean path (it already handles VERSION + package.json + CHANGELOG header + PR title atomically via Step 12 ALREADY_BUMPED detection).
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Pre-merge readiness gate
|
||||
|
||||
**This is the critical safety check before an irreversible merge.** The merge cannot
|
||||
be undone without a revert commit. Gather ALL evidence, build a readiness report,
|
||||
and get explicit user confirmation before proceeding.
|
||||
|
||||
Tell the user: "CI is green. Now I'm running readiness checks — this is the last gate before I merge. I'm checking code reviews, test results, documentation, and PR accuracy. Once you see the readiness report and approve, the merge is final."
|
||||
|
||||
Collect evidence for each check below. Track warnings (yellow) and blockers (red).
|
||||
|
||||
### 3.5a: Review staleness check
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-review-read 2>/dev/null
|
||||
```
|
||||
|
||||
Parse the output. For each review skill (plan-eng-review, plan-ceo-review,
|
||||
plan-design-review, design-review-lite, codex-review, review, adversarial-review,
|
||||
codex-plan-review):
|
||||
|
||||
1. Find the most recent entry within the last 7 days.
|
||||
2. Extract its `commit` field.
|
||||
3. Compare against current HEAD: `git rev-list --count STORED_COMMIT..HEAD`
|
||||
|
||||
**Staleness rules:**
|
||||
- 0 commits since review → CURRENT
|
||||
- 1-3 commits since review → RECENT (yellow if those commits touch code, not just docs)
|
||||
- 4+ commits since review → STALE (red — review may not reflect current code)
|
||||
- No review found → NOT RUN
|
||||
|
||||
**Critical check:** Look at what changed AFTER the last review. Run:
|
||||
```bash
|
||||
git log --oneline STORED_COMMIT..HEAD
|
||||
```
|
||||
If any commits after the review contain words like "fix", "refactor", "rewrite",
|
||||
"overhaul", or touch more than 5 files — flag as **STALE (significant changes
|
||||
since review)**. The review was done on different code than what's about to merge.
|
||||
|
||||
**Also check for adversarial review (`codex-review`).** If codex-review has been run
|
||||
and is CURRENT, mention it in the readiness report as an extra confidence signal.
|
||||
If not run, note as informational (not a blocker): "No adversarial review on record."
|
||||
|
||||
### 3.5a-bis: Inline review offer
|
||||
|
||||
**We are extra careful about deploys.** If engineering review is STALE (4+ commits since)
|
||||
or NOT RUN, offer to run a quick review inline before proceeding.
|
||||
|
||||
Use AskUserQuestion:
|
||||
- **Re-ground:** "I noticed {the code review is stale / no code review has been run} on this branch. Since this code is about to go to production, I'd like to do a quick safety check on the diff before we merge. This is one of the ways I make sure nothing ships that shouldn't."
|
||||
- **RECOMMENDATION:** Choose A for a quick safety check. Choose B if you want the full
|
||||
review experience. Choose C only if you're confident in the code.
|
||||
- A) Run a quick review (~2 min) — I'll scan the diff for common issues like SQL safety, race conditions, and security gaps (Completeness: 7/10)
|
||||
- B) Stop and run a full `/review` first — deeper analysis, more thorough (Completeness: 10/10)
|
||||
- C) Skip the review — I've reviewed this code myself and I'm confident (Completeness: 3/10)
|
||||
|
||||
**If A (quick checklist):** Tell the user: "Running the review checklist against your diff now..."
|
||||
|
||||
Read the review checklist:
|
||||
```bash
|
||||
cat ~/.claude/skills/gstack/review/checklist.md 2>/dev/null || echo "Checklist not found"
|
||||
```
|
||||
Apply each checklist item to the current diff. This is the same quick review that `/ship`
|
||||
runs in its Step 3.5. Auto-fix trivial issues (whitespace, imports). For critical findings
|
||||
(SQL safety, race conditions, security), ask the user.
|
||||
|
||||
**If any code changes are made during the quick review:** Commit the fixes, then **STOP**
|
||||
and tell the user: "I found and fixed a few issues during the review. The fixes are committed — run `/land-and-deploy` again to pick them up and continue where we left off."
|
||||
|
||||
**If no issues found:** Tell the user: "Review checklist passed — no issues found in the diff."
|
||||
|
||||
**If B:** **STOP.** "Good call — run `/review` for a thorough pre-landing review. When that's done, run `/land-and-deploy` again and I'll pick up right where we left off."
|
||||
|
||||
**If C:** Tell the user: "Understood — skipping review. You know this code best." Continue. Log the user's choice to skip review.
|
||||
|
||||
**If review is CURRENT:** Skip this sub-step entirely — no question asked.
|
||||
|
||||
### 3.5b: Test results
|
||||
|
||||
**Free tests — run them now:**
|
||||
|
||||
Read CLAUDE.md to find the project's test command. If not specified, use `bun test`.
|
||||
Run the test command and capture the exit code and output.
|
||||
|
||||
```bash
|
||||
bun test 2>&1 | tail -10
|
||||
```
|
||||
|
||||
If tests fail: **BLOCKER.** Cannot merge with failing tests.
|
||||
|
||||
**E2E tests — check recent results:**
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
ls -t ~/.gstack-dev/evals/*-e2e-*-$(date +%Y-%m-%d)*.json 2>/dev/null | head -20
|
||||
```
|
||||
|
||||
For each eval file from today, parse pass/fail counts. Show:
|
||||
- Total tests, pass count, fail count
|
||||
- How long ago the run finished (from file timestamp)
|
||||
- Total cost
|
||||
- Names of any failing tests
|
||||
|
||||
If no E2E results from today: **WARNING — no E2E tests run today.**
|
||||
If E2E results exist but have failures: **WARNING — N tests failed.** List them.
|
||||
|
||||
**LLM judge evals — check recent results:**
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
ls -t ~/.gstack-dev/evals/*-llm-judge-*-$(date +%Y-%m-%d)*.json 2>/dev/null | head -5
|
||||
```
|
||||
|
||||
If found, parse and show pass/fail. If not found, note "No LLM evals run today."
|
||||
|
||||
### 3.5c: PR body accuracy check
|
||||
|
||||
Read the current PR body:
|
||||
```bash
|
||||
gh pr view --json body -q .body
|
||||
```
|
||||
|
||||
Read the current diff summary:
|
||||
```bash
|
||||
git log --oneline $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)..HEAD | head -20
|
||||
```
|
||||
|
||||
Compare the PR body against the actual commits. Check for:
|
||||
1. **Missing features** — commits that add significant functionality not mentioned in the PR
|
||||
2. **Stale descriptions** — PR body mentions things that were later changed or reverted
|
||||
3. **Wrong version** — PR title or body references a version that doesn't match VERSION file
|
||||
|
||||
If the PR body looks stale or incomplete: **WARNING — PR body may not reflect current
|
||||
changes.** List what's missing or stale.
|
||||
|
||||
### 3.5d: Document-release check
|
||||
|
||||
Check if documentation was updated on this branch:
|
||||
|
||||
```bash
|
||||
git log --oneline --all-match --grep="docs:" $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)..HEAD | head -5
|
||||
```
|
||||
|
||||
Also check if key doc files were modified:
|
||||
```bash
|
||||
git diff --name-only $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)...HEAD -- README.md CHANGELOG.md ARCHITECTURE.md CONTRIBUTING.md CLAUDE.md VERSION
|
||||
```
|
||||
|
||||
If CHANGELOG.md and VERSION were NOT modified on this branch and the diff includes
|
||||
new features (new files, new commands, new skills): **WARNING — /document-release
|
||||
likely not run. CHANGELOG and VERSION not updated despite new features.**
|
||||
|
||||
If only docs changed (no code): skip this check.
|
||||
|
||||
### 3.5e: Readiness report and confirmation
|
||||
|
||||
Tell the user: "Here's the full readiness report. This is everything I checked before merging."
|
||||
|
||||
Build the full readiness report:
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════╗
|
||||
║ PRE-MERGE READINESS REPORT ║
|
||||
╠══════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ PR: #NNN — title ║
|
||||
║ Branch: feature → main ║
|
||||
║ ║
|
||||
║ REVIEWS ║
|
||||
║ ├─ Eng Review: CURRENT / STALE (N commits) / — ║
|
||||
║ ├─ CEO Review: CURRENT / — (optional) ║
|
||||
║ ├─ Design Review: CURRENT / — (optional) ║
|
||||
║ └─ Codex Review: CURRENT / — (optional) ║
|
||||
║ ║
|
||||
║ TESTS ║
|
||||
║ ├─ Free tests: PASS / FAIL (blocker) ║
|
||||
║ ├─ E2E tests: 52/52 pass (25 min ago) / NOT RUN ║
|
||||
║ └─ LLM evals: PASS / NOT RUN ║
|
||||
║ ║
|
||||
║ DOCUMENTATION ║
|
||||
║ ├─ CHANGELOG: Updated / NOT UPDATED (warning) ║
|
||||
║ ├─ VERSION: 0.9.8.0 / NOT BUMPED (warning) ║
|
||||
║ └─ Doc release: Run / NOT RUN (warning) ║
|
||||
║ ║
|
||||
║ PR BODY ║
|
||||
║ └─ Accuracy: Current / STALE (warning) ║
|
||||
║ ║
|
||||
║ WARNINGS: N | BLOCKERS: N ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
If there are BLOCKERS (failing free tests): list them and recommend B.
|
||||
If there are WARNINGS but no blockers: list each warning and recommend A if
|
||||
warnings are minor, or B if warnings are significant.
|
||||
If everything is green: recommend A.
|
||||
|
||||
Use AskUserQuestion:
|
||||
|
||||
- **Re-ground:** "Ready to merge PR #NNN — '{title}' into {base}. Here's what I found."
|
||||
Show the report above.
|
||||
- If everything is green: "All checks passed. This PR is ready to merge."
|
||||
- If there are warnings: List each one in plain English. E.g., "The engineering review
|
||||
was done 6 commits ago — the code has changed since then" not "STALE (6 commits)."
|
||||
- If there are blockers: "I found issues that need to be fixed before merging: {list}"
|
||||
- **RECOMMENDATION:** Choose A if green. Choose B if there are significant warnings.
|
||||
Choose C only if the user understands the risks.
|
||||
- A) Merge it — everything looks good (Completeness: 10/10)
|
||||
- B) Hold off — I want to fix the warnings first (Completeness: 10/10)
|
||||
- C) Merge anyway — I understand the warnings and want to proceed (Completeness: 3/10)
|
||||
|
||||
If the user chooses B: **STOP.** Give specific next steps:
|
||||
- If reviews are stale: "Run `/review` or `/autoplan` to review the current code, then `/land-and-deploy` again."
|
||||
- If E2E not run: "Run your E2E tests to make sure nothing is broken, then come back."
|
||||
- If docs not updated: "Run `/document-release` to update CHANGELOG and docs."
|
||||
- If PR body stale: "The PR description doesn't match what's actually in the diff — update it on GitHub."
|
||||
|
||||
If the user chooses A or C: Tell the user "Merging now." Continue to Step 4.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Merge the PR
|
||||
|
||||
Record the start timestamp for timing data. Also record which merge path is taken
|
||||
(auto-merge vs direct) for the deploy report.
|
||||
|
||||
Try auto-merge first (respects repo merge settings and merge queues):
|
||||
|
||||
```bash
|
||||
gh pr merge --auto --delete-branch
|
||||
```
|
||||
|
||||
If `--auto` succeeds: record `MERGE_PATH=auto`. This means the repo has auto-merge enabled
|
||||
and may use merge queues.
|
||||
|
||||
If `--auto` is not available (repo doesn't have auto-merge enabled), merge directly:
|
||||
|
||||
```bash
|
||||
gh pr merge --squash --delete-branch
|
||||
```
|
||||
|
||||
If direct merge succeeds: record `MERGE_PATH=direct`. Tell the user: "PR merged successfully. The branch has been cleaned up."
|
||||
|
||||
If the merge fails with a permission error: **STOP.** "I don't have permission to merge this PR. You'll need a maintainer to merge it, or check your repo's branch protection rules."
|
||||
|
||||
### 4a-postfail: Post-failure PR-state check
|
||||
|
||||
**Universal invariant:** after ANY non-zero exit from `gh pr merge`, query authoritative PR state before retrying or stopping. Do NOT retry `gh pr merge`. Related: cli/cli#3442, cli/cli#13380.
|
||||
|
||||
```bash
|
||||
gh pr view --json state,mergeCommit,mergedAt,mergedBy
|
||||
```
|
||||
|
||||
**If `state == "MERGED"`:**
|
||||
|
||||
The server-side merge succeeded (possibly completed before the local cleanup phase failed, or a concurrent merge landed). Tell the user: "PR is merged on GitHub." (Do NOT say "the merge succeeded" — this handles the concurrent-merge case.)
|
||||
|
||||
Capture merge SHA:
|
||||
```bash
|
||||
gh pr view --json mergeCommit -q .mergeCommit.oid
|
||||
```
|
||||
|
||||
Worktree cleanup — non-destructive, candidate-based:
|
||||
```bash
|
||||
git worktree list --porcelain
|
||||
```
|
||||
Identify candidates: a worktree is stale if (a) it is checked out on the base branch, AND (b) it is not the user's current main working tree, AND (c) `git status --porcelain` inside it is empty (no uncommitted work).
|
||||
|
||||
- For each clean candidate: OFFER to remove it. Say: "There's a stale worktree at `<path>` checked out on `<branch>` with no uncommitted work. Remove it?" Remove only if user confirms (`git worktree remove <path> && git worktree prune`).
|
||||
- If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
|
||||
- Do NOT use `--force`. Do NOT remove the user's primary working tree.
|
||||
|
||||
Record `MERGE_PATH=direct`, then continue to §4a (CI auto-deploy detection).
|
||||
|
||||
**If `state == "OPEN"`:**
|
||||
|
||||
Check whether auto-merge is enabled:
|
||||
```bash
|
||||
gh pr view --json autoMergeRequest -q .autoMergeRequest
|
||||
```
|
||||
|
||||
- If non-null: auto-merge is enabled or merge queue is in use. The open state is expected — proceed to §4a's merge-queue wait path.
|
||||
- If null: genuine failure. Surface both errors — the `gh pr merge` stderr AND the current PR open state — then **STOP**.
|
||||
|
||||
**If `state == "CLOSED"`:** PR was closed without merging. **STOP.**
|
||||
|
||||
**Hard rule: never call `gh pr merge` a second time** after a non-zero exit. Server state is authoritative.
|
||||
|
||||
### 4a: Merge queue detection and messaging
|
||||
|
||||
If `MERGE_PATH=auto` and the PR state does not immediately become `MERGED`, the PR is
|
||||
in a **merge queue**. Tell the user:
|
||||
|
||||
"Your repo uses a merge queue — that means GitHub will run CI one more time on the final merge commit before it actually merges. This is a good thing (it catches last-minute conflicts), but it means we wait. I'll keep checking until it goes through."
|
||||
|
||||
Poll for the PR to actually merge:
|
||||
|
||||
```bash
|
||||
gh pr view --json state -q .state
|
||||
```
|
||||
|
||||
Poll every 30 seconds, up to 30 minutes. Show a progress message every 2 minutes:
|
||||
"Still in the merge queue... ({X}m so far)"
|
||||
|
||||
If the PR state changes to `MERGED`: capture the merge commit SHA. Tell the user:
|
||||
"Merge queue finished — PR is merged. Took {duration}."
|
||||
|
||||
If the PR is removed from the queue (state goes back to `OPEN`): **STOP.** "The PR was removed from the merge queue — this usually means a CI check failed on the merge commit, or another PR in the queue caused a conflict. Check the GitHub merge queue page to see what happened."
|
||||
If timeout (30 min): **STOP.** "The merge queue has been processing for 30 minutes. Something might be stuck — check the GitHub Actions tab and the merge queue page."
|
||||
|
||||
### 4b: CI auto-deploy detection
|
||||
|
||||
After the PR is merged, check if a deploy workflow was triggered by the merge:
|
||||
After the PR has landed, check if a deploy workflow was triggered by the merge. Match
|
||||
on `LAND_SHA` (the merge commit captured in Step 2):
|
||||
|
||||
```bash
|
||||
gh run list --branch <base> --limit 5 --json name,status,workflowName,headSha
|
||||
```
|
||||
|
||||
Look for runs matching the merge commit SHA. If a deploy workflow is found:
|
||||
- Tell the user: "PR merged. I can see a deploy workflow ('{workflow-name}') kicked off automatically. I'll monitor it and let you know when it's done."
|
||||
Look for runs whose `headSha` matches `LAND_SHA`. If a deploy workflow is found:
|
||||
- Tell the user: "PR landed. I can see a deploy workflow ('{workflow-name}') kicked off automatically. I'll monitor it and let you know when it's done."
|
||||
|
||||
If no deploy workflow is found after merge:
|
||||
- Tell the user: "PR merged. I don't see a deploy workflow — your project might deploy a different way, or it might be a library/CLI that doesn't have a deploy step. I'll figure out the right verification in the next step."
|
||||
If no deploy workflow is found after the merge:
|
||||
- Tell the user: "PR landed. I don't see a deploy workflow — your project might deploy a different way, or it might be a library/CLI that doesn't have a deploy step. I'll figure out the right verification in the next step."
|
||||
|
||||
If `MERGE_PATH=auto` and the repo uses merge queues AND a deploy workflow exists:
|
||||
If `LAND_REGIME` is `github` or `trunk` (a merge queue) AND a deploy workflow exists:
|
||||
- Tell the user: "PR made it through the merge queue and the deploy workflow is running. Monitoring it now."
|
||||
|
||||
Record merge timestamp, duration, and merge path for the deploy report.
|
||||
Record the landing timestamp and `LAND_REGIME` for the deploy report.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1628,7 +1327,7 @@ If a deploy workflow was detected, find the run triggered by the merge commit:
|
|||
gh run list --branch <base> --limit 10 --json databaseId,headSha,status,conclusion,name,workflowName
|
||||
```
|
||||
|
||||
Match by the merge commit SHA (captured in Step 4). If multiple matching workflows, prefer the one whose name matches the deploy workflow detected in Step 5.
|
||||
Match by `LAND_SHA` (the merge commit captured in Step 2). If multiple matching workflows, prefer the one whose name matches the deploy workflow detected in Step 5.
|
||||
|
||||
Poll every 30 seconds:
|
||||
```bash
|
||||
|
|
@ -1750,19 +1449,35 @@ If the user chose to revert at any point:
|
|||
|
||||
Tell the user: "Reverting the merge now. This will create a new commit that undoes all the changes from this PR. The previous version of your site will be restored once the revert deploys."
|
||||
|
||||
Use `LAND_SHA` (the confirmed merge commit from Step 2) as the revert target.
|
||||
|
||||
**Merge-queue / protected branches first (H8).** If `LAND_REGIME` is `github` or `trunk`,
|
||||
a direct push to the base branch is almost always blocked by branch protection, so go
|
||||
straight to a revert PR — do not attempt a direct push:
|
||||
|
||||
```bash
|
||||
git fetch origin <base>
|
||||
git checkout -b revert-pr-<NNN> origin/<base>
|
||||
git revert <LAND_SHA> --no-edit
|
||||
git push origin revert-pr-<NNN>
|
||||
gh pr create --base <base> --head revert-pr-<NNN> --title 'revert: <original PR title>' --fill
|
||||
```
|
||||
Tell the user: "This repo uses a merge queue / protected branch, so I opened a revert PR. Merge it (it can ride the queue too) to roll back."
|
||||
|
||||
**No-queue repos (`LAND_REGIME` is `none`).** Try the direct push first:
|
||||
|
||||
```bash
|
||||
git fetch origin <base>
|
||||
git checkout <base>
|
||||
git revert <merge-commit-sha> --no-edit
|
||||
git revert <LAND_SHA> --no-edit
|
||||
git push origin <base>
|
||||
```
|
||||
|
||||
If the revert has conflicts: "The revert has merge conflicts — this can happen if other changes landed on {base} after your merge. You'll need to resolve the conflicts manually. The merge commit SHA is `<sha>` — run `git revert <sha>` to try again."
|
||||
If the revert has conflicts: "The revert has merge conflicts — this can happen if other changes landed on {base} after your merge. You'll need to resolve them manually. The merge commit SHA is `<LAND_SHA>` — run `git revert <LAND_SHA>` to try again."
|
||||
|
||||
If the base branch has push protections: "This repo has branch protections, so I can't push the revert directly. I'll create a revert PR instead — merge it to roll back."
|
||||
Then create a revert PR: `gh pr create --title 'revert: <original PR title>'`
|
||||
If the direct push is rejected by branch protection: fall back to the revert-PR flow above.
|
||||
|
||||
After a successful revert: Tell the user "Revert pushed to {base}. The deploy should roll back automatically once CI passes. Keep an eye on the site to confirm." Note the revert commit SHA and continue to Step 9 with status REVERTED.
|
||||
After a successful revert (pushed or PR merged): Tell the user "Revert is in — the deploy should roll back automatically once CI passes. Keep an eye on the site to confirm." Note the revert commit SHA and continue to Step 9 with status REVERTED.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1781,15 +1496,14 @@ LAND & DEPLOY REPORT
|
|||
═════════════════════
|
||||
PR: #<number> — <title>
|
||||
Branch: <head-branch> → <base-branch>
|
||||
Merged: <timestamp> (<merge method>)
|
||||
Merge SHA: <sha>
|
||||
Merge path: <auto-merge / direct / merge queue>
|
||||
Landed: <timestamp>
|
||||
Merge SHA: <LAND_SHA>
|
||||
Merge regime: <none / github / trunk> (landing handled by /land)
|
||||
First run: <yes (dry-run validated) / no (previously confirmed)>
|
||||
|
||||
Timing:
|
||||
Dry-run: <duration or "skipped (confirmed)">
|
||||
CI wait: <duration>
|
||||
Queue: <duration or "direct merge">
|
||||
Land: <duration of /land — CI wait + queue + merge>
|
||||
Deploy: <duration or "no workflow detected">
|
||||
Staging: <duration or "skipped">
|
||||
Canary: <duration or "skipped">
|
||||
|
|
@ -1822,7 +1536,7 @@ mkdir -p ~/.gstack/projects/$SLUG
|
|||
|
||||
Write a JSONL entry with timing data:
|
||||
```json
|
||||
{"skill":"land-and-deploy","timestamp":"<ISO>","status":"<SUCCESS/REVERTED>","pr":<number>,"merge_sha":"<sha>","merge_path":"<auto/direct/queue>","first_run":<true/false>,"deploy_status":"<HEALTHY/DEGRADED/SKIPPED>","staging_status":"<VERIFIED/SKIPPED>","review_status":"<CURRENT/STALE/NOT_RUN/INLINE_FIX>","ci_wait_s":<N>,"queue_s":<N>,"deploy_s":<N>,"staging_s":<N>,"canary_s":<N>,"total_s":<N>}
|
||||
{"skill":"land-and-deploy","timestamp":"<ISO>","status":"<SUCCESS/REVERTED>","pr":<number>,"merge_sha":"<LAND_SHA>","merge_regime":"<none/github/trunk>","first_run":<true/false>,"deploy_status":"<HEALTHY/DEGRADED/SKIPPED>","staging_status":"<VERIFIED/SKIPPED>","review_status":"<CURRENT/STALE/NOT_RUN/INLINE_FIX>","land_s":<N>,"deploy_s":<N>,"staging_s":<N>,"canary_s":<N>,"total_s":<N>}
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1851,9 +1565,10 @@ Then suggest relevant follow-ups:
|
|||
- **Narrate the journey.** The user should always know: what just happened, what's happening now, and what's about to happen next. No silent gaps between steps.
|
||||
- **Auto-detect everything.** PR number, merge method, deploy strategy, project type, merge queues, staging environments. Only ask when information genuinely can't be inferred.
|
||||
- **Poll with backoff.** Don't hammer GitHub API. 30-second intervals for CI/deploy, with reasonable timeouts.
|
||||
- **Revert is always an option.** At every failure point, offer revert as an escape hatch. Explain what reverting does in plain English.
|
||||
- **Revert is always an option.** At every failure point, offer revert as an escape hatch (Step 8 uses `LAND_SHA` and goes PR-first on queue/protected branches). Explain what reverting does in plain English.
|
||||
- **Single-pass verification, not continuous monitoring.** `/land-and-deploy` checks once. `/canary` does the extended monitoring loop.
|
||||
- **Clean up.** Delete the feature branch after merge (via `--delete-branch`).
|
||||
- **Branch cleanup belongs to `/land`.** `/land` deletes the feature branch on the no-queue/GitHub paths; on the trunk path, Trunk owns branch cleanup. Don't delete branches here.
|
||||
- **The merge lives in `/land`.** This skill never calls `gh pr merge` itself — it composes `/land` (Step 2) and consumes the landing handoff. Keep merge logic in one place.
|
||||
- **First run = teacher mode.** Walk the user through everything. Explain what each check does and why it matters. Show them their infrastructure. Let them confirm before proceeding. Build trust through transparency.
|
||||
- **Subsequent runs = efficient mode.** Brief status updates, no re-explanations. The user already trusts the tool — just do the job and report results.
|
||||
- **The goal is: first-timers think "wow, this is thorough — I trust it." Repeat users think "that was fast — it just works."**
|
||||
|
|
|
|||
|
|
@ -51,16 +51,17 @@ readiness first.
|
|||
|
||||
**Always stop for:**
|
||||
- **First-run dry-run validation (Step 1.5)** — shows deploy infrastructure and confirms setup
|
||||
- **Pre-merge readiness gate (Step 3.5)** — reviews, tests, docs check before merge
|
||||
- **Pre-merge readiness gate** — owned by `/land` (its Step 3.5: reviews, tests, docs, then the single irreversible-merge confirmation)
|
||||
- GitHub CLI not authenticated
|
||||
- No PR found for this branch
|
||||
- CI failures or merge conflicts
|
||||
- Permission denied on merge
|
||||
- CI failures or merge conflicts (surfaced by `/land`)
|
||||
- Permission denied on merge / merge-queue ejection (surfaced by `/land`)
|
||||
- Landing could not be confirmed (no merge SHA in the handoff)
|
||||
- Deploy workflow failure (offer revert)
|
||||
- Production health issues detected by canary (offer revert)
|
||||
|
||||
**Never stop for:**
|
||||
- Choosing merge method (auto-detect from repo settings)
|
||||
- Choosing the merge regime (`/land` resolves it: config → detect → ask once → persist)
|
||||
- Timeout warnings (warn and continue gracefully)
|
||||
|
||||
## Voice & Tone
|
||||
|
|
@ -98,9 +99,16 @@ gh pr view --json number,state,title,url,mergeStateStatus,mergeable,baseRefName,
|
|||
|
||||
5. Validate the PR state:
|
||||
- If no PR exists: **STOP.** "No PR found for this branch. Run `/ship` first to create a PR, then come back here to land and deploy it."
|
||||
- If `state` is `MERGED`: "This PR is already merged — nothing to deploy. If you need to verify the deploy, run `/canary <url>` instead."
|
||||
- If `state` is `CLOSED`: "This PR was closed without merging. Reopen it on GitHub first, then try again."
|
||||
- If `state` is `OPEN`: continue.
|
||||
- If `state` is `OPEN`: continue to Step 1.5.
|
||||
- If `state` is `MERGED` (already-landed re-run — e.g. you deployed to staging only earlier, or a previous run merged then stopped): do NOT re-invoke `/land`. Try to consume the landing record instead:
|
||||
```bash
|
||||
{{SLUG_EVAL}}
|
||||
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)
|
||||
~/.claude/skills/gstack/bin/gstack-merge read-state --slug "$SLUG" --pr <NNN> --repo "$REPO"
|
||||
```
|
||||
- If it prints `LAND_SHA=...` (valid handoff for this PR): tell the user "This PR already landed — skipping the merge and going straight to deploy verification." Capture `LAND_SHA`/`LAND_BASE`/`LAND_REGIME`/`LAND_HEAD`, then skip Steps 1.5 and 2 and go to **Step 3** (post-merge CI auto-deploy detection).
|
||||
- If it prints `READ_STATE_INVALID=...` (no/stale/foreign handoff): **STOP.** "This PR is already merged but I have no landing record for it, so I can't safely match the deploy or offer a revert. Run `/canary <url>` to verify the live site, or revert manually if needed."
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -173,8 +181,14 @@ gh auth status 2>&1 | head -3
|
|||
|
||||
# Test production URL reachability
|
||||
# curl -sf {production-url} -o /dev/null -w "%{http_code}" 2>/dev/null
|
||||
|
||||
# Detect the merge regime with the SAME helper /land will use (so the dry-run
|
||||
# table tells the truth — no separate detection logic that could disagree).
|
||||
~/.claude/skills/gstack/bin/gstack-merge detect --json 2>/dev/null
|
||||
```
|
||||
|
||||
Parse the `gstack-merge detect` output (`{"regime":"none|github|trunk","source":"..."}`) and use it for the MERGE QUEUE / MERGE METHOD rows below. This is informational only — nothing is merged here.
|
||||
|
||||
Run whichever commands are relevant based on the detected platform. Build the results into this table:
|
||||
|
||||
```
|
||||
|
|
@ -204,8 +218,9 @@ Run whichever commands are relevant based on the detected platform. Build the re
|
|||
║ 4. {Wait for deploy workflow / Wait 60s / Skip} ║
|
||||
║ 5. {Run canary verification / Skip (no URL)} ║
|
||||
║ ║
|
||||
║ MERGE METHOD: {squash/merge/rebase} (from repo settings) ║
|
||||
║ MERGE QUEUE: {detected / not detected} ║
|
||||
║ MERGE REGIME: {none / github / trunk} (from {source}) ║
|
||||
║ MERGE QUEUE: {none / GitHub native / trunk.io} ║
|
||||
║ MERGED BY: /land (Step 2) — readiness gate + merge ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
|
|
@ -289,414 +304,80 @@ Continue to Step 2.
|
|||
|
||||
---
|
||||
|
||||
## Step 2: Pre-merge checks
|
||||
## Step 2: Land the PR (compose /land)
|
||||
|
||||
Tell the user: "Checking CI status and merge readiness..."
|
||||
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.
|
||||
|
||||
Check CI status and merge readiness:
|
||||
**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}}
|
||||
|
||||
`/land`'s readiness gate (its Step 3.5) owns the single irreversible-merge
|
||||
confirmation. The dry-run above was informational only — do NOT add a second merge
|
||||
confirmation here.
|
||||
|
||||
### 2.1: Consume the landing handoff
|
||||
|
||||
`/land` writes a `last-land.json` handoff and prints a `LANDED:` line. Skill composition
|
||||
does not return structured data across the boundary, so read the file explicitly and
|
||||
validate it before touching any deploy or revert path:
|
||||
|
||||
```bash
|
||||
gh pr checks --json name,state,status,conclusion
|
||||
{{SLUG_EVAL}}
|
||||
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)
|
||||
~/.claude/skills/gstack/bin/gstack-merge read-state --slug "$SLUG" --pr <NNN> --repo "$REPO"
|
||||
```
|
||||
|
||||
Parse the output:
|
||||
1. If any required checks are **FAILING**: **STOP.** "CI is failing on this PR. Here are the failing checks: {list}. Fix these before deploying — I won't merge code that hasn't passed CI."
|
||||
2. If required checks are **PENDING**: Tell the user "CI is still running. I'll wait for it to finish." Proceed to Step 3.
|
||||
3. If all checks pass (or no required checks): Tell the user "CI passed." Skip Step 3, go to Step 4.
|
||||
- **`READ_STATE_INVALID=...`** (no / stale / wrong-PR / wrong-repo handoff): **STOP.**
|
||||
"I couldn't confirm this PR actually landed (no valid landing record). I won't deploy
|
||||
off an unconfirmed merge. Re-run `/land` and check why it didn't complete."
|
||||
- **`LAND_SHA=... LAND_BASE=... LAND_REGIME=... LAND_HEAD=...`**: capture these. `LAND_SHA`
|
||||
is the merge commit on the base branch — every deploy-workflow match and any revert
|
||||
uses it.
|
||||
|
||||
### 2.2: Verify the SHA is really on the base branch (H2)
|
||||
|
||||
Don't trust metadata alone — confirm the commit actually landed on the base:
|
||||
|
||||
Also check for merge conflicts:
|
||||
```bash
|
||||
gh pr view --json mergeable -q .mergeable
|
||||
git fetch origin <base>
|
||||
git merge-base --is-ancestor <LAND_SHA> origin/<base> && echo "ON_BASE" || echo "NOT_ON_BASE"
|
||||
```
|
||||
If `CONFLICTING`: **STOP.** "This PR has merge conflicts with the base branch. Resolve the conflicts and push, then run `/land-and-deploy` again."
|
||||
|
||||
If `NOT_ON_BASE`: **STOP.** "GitHub reports the PR merged, but `<LAND_SHA>` isn't on
|
||||
`origin/<base>` yet. Wait a moment and re-run `/land-and-deploy`, or check the repo —
|
||||
I won't deploy or offer a revert against a commit I can't see on the base branch."
|
||||
|
||||
If `ON_BASE`: the PR has truly landed. Continue to Step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Wait for CI (if pending)
|
||||
## Step 3: Post-merge CI auto-deploy detection
|
||||
|
||||
If required checks are still pending, wait for them to complete. Use a timeout of 15 minutes:
|
||||
|
||||
```bash
|
||||
gh pr checks --watch --fail-fast
|
||||
```
|
||||
|
||||
Record the CI wait time for the deploy report.
|
||||
|
||||
If CI passes within the timeout: Tell the user "CI passed after {duration}. Moving to readiness checks." Continue to Step 4.
|
||||
If CI fails: **STOP.** "CI failed. Here's what broke: {failures}. This needs to pass before I can merge."
|
||||
If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that's unusual. Check the GitHub Actions tab to see if something is stuck."
|
||||
|
||||
---
|
||||
|
||||
## Step 3.4: VERSION drift detection (workspace-aware ship)
|
||||
|
||||
Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
|
||||
# Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection)
|
||||
# We don't need the exact original level — we just need "a level" that passes to the util.
|
||||
# If the minor digit advanced, call it minor; patch digit, patch; etc. If base > branch, skip (not ours to land).
|
||||
# For simplicity: use "patch" as a conservative default; util handles collision-past regardless of input level.
|
||||
QUEUE_JSON=$(bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
1. If `OFFLINE=true` or the util fails: print `⚠ VERSION drift check unavailable (util offline) — proceeding with PR version v<BRANCH_VERSION>`. Continue to Step 3.5. CI's version-gate job is the backstop.
|
||||
|
||||
2. If `BRANCH_VERSION` is already `>=` than `NEXT_SLOT`: no drift (or our PR is ahead of the queue). Continue.
|
||||
|
||||
3. If drift is detected (a PR landed ahead of us and `BRANCH_VERSION < NEXT_SLOT`): **STOP** and print exactly:
|
||||
```
|
||||
⚠ VERSION drift detected.
|
||||
This PR claims: v<BRANCH_VERSION>
|
||||
Next free slot: v<NEXT_SLOT> (queue moved since last /ship)
|
||||
|
||||
Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED
|
||||
branch will detect the drift and rewrite VERSION + CHANGELOG header + PR title
|
||||
atomically. Do NOT merge from here — the landed PR would overwrite the other
|
||||
branch's CHANGELOG entry or land with a duplicate version header.
|
||||
```
|
||||
|
||||
Exit non-zero. Do NOT auto-bump from `/land-and-deploy` — rerunning `/ship` is the clean path (it already handles VERSION + package.json + CHANGELOG header + PR title atomically via Step 12 ALREADY_BUMPED detection).
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Pre-merge readiness gate
|
||||
|
||||
**This is the critical safety check before an irreversible merge.** The merge cannot
|
||||
be undone without a revert commit. Gather ALL evidence, build a readiness report,
|
||||
and get explicit user confirmation before proceeding.
|
||||
|
||||
Tell the user: "CI is green. Now I'm running readiness checks — this is the last gate before I merge. I'm checking code reviews, test results, documentation, and PR accuracy. Once you see the readiness report and approve, the merge is final."
|
||||
|
||||
Collect evidence for each check below. Track warnings (yellow) and blockers (red).
|
||||
|
||||
### 3.5a: Review staleness check
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-review-read 2>/dev/null
|
||||
```
|
||||
|
||||
Parse the output. For each review skill (plan-eng-review, plan-ceo-review,
|
||||
plan-design-review, design-review-lite, codex-review, review, adversarial-review,
|
||||
codex-plan-review):
|
||||
|
||||
1. Find the most recent entry within the last 7 days.
|
||||
2. Extract its `commit` field.
|
||||
3. Compare against current HEAD: `git rev-list --count STORED_COMMIT..HEAD`
|
||||
|
||||
**Staleness rules:**
|
||||
- 0 commits since review → CURRENT
|
||||
- 1-3 commits since review → RECENT (yellow if those commits touch code, not just docs)
|
||||
- 4+ commits since review → STALE (red — review may not reflect current code)
|
||||
- No review found → NOT RUN
|
||||
|
||||
**Critical check:** Look at what changed AFTER the last review. Run:
|
||||
```bash
|
||||
git log --oneline STORED_COMMIT..HEAD
|
||||
```
|
||||
If any commits after the review contain words like "fix", "refactor", "rewrite",
|
||||
"overhaul", or touch more than 5 files — flag as **STALE (significant changes
|
||||
since review)**. The review was done on different code than what's about to merge.
|
||||
|
||||
**Also check for adversarial review (`codex-review`).** If codex-review has been run
|
||||
and is CURRENT, mention it in the readiness report as an extra confidence signal.
|
||||
If not run, note as informational (not a blocker): "No adversarial review on record."
|
||||
|
||||
### 3.5a-bis: Inline review offer
|
||||
|
||||
**We are extra careful about deploys.** If engineering review is STALE (4+ commits since)
|
||||
or NOT RUN, offer to run a quick review inline before proceeding.
|
||||
|
||||
Use AskUserQuestion:
|
||||
- **Re-ground:** "I noticed {the code review is stale / no code review has been run} on this branch. Since this code is about to go to production, I'd like to do a quick safety check on the diff before we merge. This is one of the ways I make sure nothing ships that shouldn't."
|
||||
- **RECOMMENDATION:** Choose A for a quick safety check. Choose B if you want the full
|
||||
review experience. Choose C only if you're confident in the code.
|
||||
- A) Run a quick review (~2 min) — I'll scan the diff for common issues like SQL safety, race conditions, and security gaps (Completeness: 7/10)
|
||||
- B) Stop and run a full `/review` first — deeper analysis, more thorough (Completeness: 10/10)
|
||||
- C) Skip the review — I've reviewed this code myself and I'm confident (Completeness: 3/10)
|
||||
|
||||
**If A (quick checklist):** Tell the user: "Running the review checklist against your diff now..."
|
||||
|
||||
Read the review checklist:
|
||||
```bash
|
||||
cat ~/.claude/skills/gstack/review/checklist.md 2>/dev/null || echo "Checklist not found"
|
||||
```
|
||||
Apply each checklist item to the current diff. This is the same quick review that `/ship`
|
||||
runs in its Step 3.5. Auto-fix trivial issues (whitespace, imports). For critical findings
|
||||
(SQL safety, race conditions, security), ask the user.
|
||||
|
||||
**If any code changes are made during the quick review:** Commit the fixes, then **STOP**
|
||||
and tell the user: "I found and fixed a few issues during the review. The fixes are committed — run `/land-and-deploy` again to pick them up and continue where we left off."
|
||||
|
||||
**If no issues found:** Tell the user: "Review checklist passed — no issues found in the diff."
|
||||
|
||||
**If B:** **STOP.** "Good call — run `/review` for a thorough pre-landing review. When that's done, run `/land-and-deploy` again and I'll pick up right where we left off."
|
||||
|
||||
**If C:** Tell the user: "Understood — skipping review. You know this code best." Continue. Log the user's choice to skip review.
|
||||
|
||||
**If review is CURRENT:** Skip this sub-step entirely — no question asked.
|
||||
|
||||
### 3.5b: Test results
|
||||
|
||||
**Free tests — run them now:**
|
||||
|
||||
Read CLAUDE.md to find the project's test command. If not specified, use `bun test`.
|
||||
Run the test command and capture the exit code and output.
|
||||
|
||||
```bash
|
||||
bun test 2>&1 | tail -10
|
||||
```
|
||||
|
||||
If tests fail: **BLOCKER.** Cannot merge with failing tests.
|
||||
|
||||
**E2E tests — check recent results:**
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
ls -t ~/.gstack-dev/evals/*-e2e-*-$(date +%Y-%m-%d)*.json 2>/dev/null | head -20
|
||||
```
|
||||
|
||||
For each eval file from today, parse pass/fail counts. Show:
|
||||
- Total tests, pass count, fail count
|
||||
- How long ago the run finished (from file timestamp)
|
||||
- Total cost
|
||||
- Names of any failing tests
|
||||
|
||||
If no E2E results from today: **WARNING — no E2E tests run today.**
|
||||
If E2E results exist but have failures: **WARNING — N tests failed.** List them.
|
||||
|
||||
**LLM judge evals — check recent results:**
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
ls -t ~/.gstack-dev/evals/*-llm-judge-*-$(date +%Y-%m-%d)*.json 2>/dev/null | head -5
|
||||
```
|
||||
|
||||
If found, parse and show pass/fail. If not found, note "No LLM evals run today."
|
||||
|
||||
### 3.5c: PR body accuracy check
|
||||
|
||||
Read the current PR body:
|
||||
```bash
|
||||
gh pr view --json body -q .body
|
||||
```
|
||||
|
||||
Read the current diff summary:
|
||||
```bash
|
||||
git log --oneline $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)..HEAD | head -20
|
||||
```
|
||||
|
||||
Compare the PR body against the actual commits. Check for:
|
||||
1. **Missing features** — commits that add significant functionality not mentioned in the PR
|
||||
2. **Stale descriptions** — PR body mentions things that were later changed or reverted
|
||||
3. **Wrong version** — PR title or body references a version that doesn't match VERSION file
|
||||
|
||||
If the PR body looks stale or incomplete: **WARNING — PR body may not reflect current
|
||||
changes.** List what's missing or stale.
|
||||
|
||||
### 3.5d: Document-release check
|
||||
|
||||
Check if documentation was updated on this branch:
|
||||
|
||||
```bash
|
||||
git log --oneline --all-match --grep="docs:" $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)..HEAD | head -5
|
||||
```
|
||||
|
||||
Also check if key doc files were modified:
|
||||
```bash
|
||||
git diff --name-only $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)...HEAD -- README.md CHANGELOG.md ARCHITECTURE.md CONTRIBUTING.md CLAUDE.md VERSION
|
||||
```
|
||||
|
||||
If CHANGELOG.md and VERSION were NOT modified on this branch and the diff includes
|
||||
new features (new files, new commands, new skills): **WARNING — /document-release
|
||||
likely not run. CHANGELOG and VERSION not updated despite new features.**
|
||||
|
||||
If only docs changed (no code): skip this check.
|
||||
|
||||
### 3.5e: Readiness report and confirmation
|
||||
|
||||
Tell the user: "Here's the full readiness report. This is everything I checked before merging."
|
||||
|
||||
Build the full readiness report:
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════╗
|
||||
║ PRE-MERGE READINESS REPORT ║
|
||||
╠══════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ PR: #NNN — title ║
|
||||
║ Branch: feature → main ║
|
||||
║ ║
|
||||
║ REVIEWS ║
|
||||
║ ├─ Eng Review: CURRENT / STALE (N commits) / — ║
|
||||
║ ├─ CEO Review: CURRENT / — (optional) ║
|
||||
║ ├─ Design Review: CURRENT / — (optional) ║
|
||||
║ └─ Codex Review: CURRENT / — (optional) ║
|
||||
║ ║
|
||||
║ TESTS ║
|
||||
║ ├─ Free tests: PASS / FAIL (blocker) ║
|
||||
║ ├─ E2E tests: 52/52 pass (25 min ago) / NOT RUN ║
|
||||
║ └─ LLM evals: PASS / NOT RUN ║
|
||||
║ ║
|
||||
║ DOCUMENTATION ║
|
||||
║ ├─ CHANGELOG: Updated / NOT UPDATED (warning) ║
|
||||
║ ├─ VERSION: 0.9.8.0 / NOT BUMPED (warning) ║
|
||||
║ └─ Doc release: Run / NOT RUN (warning) ║
|
||||
║ ║
|
||||
║ PR BODY ║
|
||||
║ └─ Accuracy: Current / STALE (warning) ║
|
||||
║ ║
|
||||
║ WARNINGS: N | BLOCKERS: N ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
If there are BLOCKERS (failing free tests): list them and recommend B.
|
||||
If there are WARNINGS but no blockers: list each warning and recommend A if
|
||||
warnings are minor, or B if warnings are significant.
|
||||
If everything is green: recommend A.
|
||||
|
||||
Use AskUserQuestion:
|
||||
|
||||
- **Re-ground:** "Ready to merge PR #NNN — '{title}' into {base}. Here's what I found."
|
||||
Show the report above.
|
||||
- If everything is green: "All checks passed. This PR is ready to merge."
|
||||
- If there are warnings: List each one in plain English. E.g., "The engineering review
|
||||
was done 6 commits ago — the code has changed since then" not "STALE (6 commits)."
|
||||
- If there are blockers: "I found issues that need to be fixed before merging: {list}"
|
||||
- **RECOMMENDATION:** Choose A if green. Choose B if there are significant warnings.
|
||||
Choose C only if the user understands the risks.
|
||||
- A) Merge it — everything looks good (Completeness: 10/10)
|
||||
- B) Hold off — I want to fix the warnings first (Completeness: 10/10)
|
||||
- C) Merge anyway — I understand the warnings and want to proceed (Completeness: 3/10)
|
||||
|
||||
If the user chooses B: **STOP.** Give specific next steps:
|
||||
- If reviews are stale: "Run `/review` or `/autoplan` to review the current code, then `/land-and-deploy` again."
|
||||
- If E2E not run: "Run your E2E tests to make sure nothing is broken, then come back."
|
||||
- If docs not updated: "Run `/document-release` to update CHANGELOG and docs."
|
||||
- If PR body stale: "The PR description doesn't match what's actually in the diff — update it on GitHub."
|
||||
|
||||
If the user chooses A or C: Tell the user "Merging now." Continue to Step 4.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Merge the PR
|
||||
|
||||
Record the start timestamp for timing data. Also record which merge path is taken
|
||||
(auto-merge vs direct) for the deploy report.
|
||||
|
||||
Try auto-merge first (respects repo merge settings and merge queues):
|
||||
|
||||
```bash
|
||||
gh pr merge --auto --delete-branch
|
||||
```
|
||||
|
||||
If `--auto` succeeds: record `MERGE_PATH=auto`. This means the repo has auto-merge enabled
|
||||
and may use merge queues.
|
||||
|
||||
If `--auto` is not available (repo doesn't have auto-merge enabled), merge directly:
|
||||
|
||||
```bash
|
||||
gh pr merge --squash --delete-branch
|
||||
```
|
||||
|
||||
If direct merge succeeds: record `MERGE_PATH=direct`. Tell the user: "PR merged successfully. The branch has been cleaned up."
|
||||
|
||||
If the merge fails with a permission error: **STOP.** "I don't have permission to merge this PR. You'll need a maintainer to merge it, or check your repo's branch protection rules."
|
||||
|
||||
### 4a-postfail: Post-failure PR-state check
|
||||
|
||||
**Universal invariant:** after ANY non-zero exit from `gh pr merge`, query authoritative PR state before retrying or stopping. Do NOT retry `gh pr merge`. Related: cli/cli#3442, cli/cli#13380.
|
||||
|
||||
```bash
|
||||
gh pr view --json state,mergeCommit,mergedAt,mergedBy
|
||||
```
|
||||
|
||||
**If `state == "MERGED"`:**
|
||||
|
||||
The server-side merge succeeded (possibly completed before the local cleanup phase failed, or a concurrent merge landed). Tell the user: "PR is merged on GitHub." (Do NOT say "the merge succeeded" — this handles the concurrent-merge case.)
|
||||
|
||||
Capture merge SHA:
|
||||
```bash
|
||||
gh pr view --json mergeCommit -q .mergeCommit.oid
|
||||
```
|
||||
|
||||
Worktree cleanup — non-destructive, candidate-based:
|
||||
```bash
|
||||
git worktree list --porcelain
|
||||
```
|
||||
Identify candidates: a worktree is stale if (a) it is checked out on the base branch, AND (b) it is not the user's current main working tree, AND (c) `git status --porcelain` inside it is empty (no uncommitted work).
|
||||
|
||||
- For each clean candidate: OFFER to remove it. Say: "There's a stale worktree at `<path>` checked out on `<branch>` with no uncommitted work. Remove it?" Remove only if user confirms (`git worktree remove <path> && git worktree prune`).
|
||||
- If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
|
||||
- Do NOT use `--force`. Do NOT remove the user's primary working tree.
|
||||
|
||||
Record `MERGE_PATH=direct`, then continue to §4a (CI auto-deploy detection).
|
||||
|
||||
**If `state == "OPEN"`:**
|
||||
|
||||
Check whether auto-merge is enabled:
|
||||
```bash
|
||||
gh pr view --json autoMergeRequest -q .autoMergeRequest
|
||||
```
|
||||
|
||||
- If non-null: auto-merge is enabled or merge queue is in use. The open state is expected — proceed to §4a's merge-queue wait path.
|
||||
- If null: genuine failure. Surface both errors — the `gh pr merge` stderr AND the current PR open state — then **STOP**.
|
||||
|
||||
**If `state == "CLOSED"`:** PR was closed without merging. **STOP.**
|
||||
|
||||
**Hard rule: never call `gh pr merge` a second time** after a non-zero exit. Server state is authoritative.
|
||||
|
||||
### 4a: Merge queue detection and messaging
|
||||
|
||||
If `MERGE_PATH=auto` and the PR state does not immediately become `MERGED`, the PR is
|
||||
in a **merge queue**. Tell the user:
|
||||
|
||||
"Your repo uses a merge queue — that means GitHub will run CI one more time on the final merge commit before it actually merges. This is a good thing (it catches last-minute conflicts), but it means we wait. I'll keep checking until it goes through."
|
||||
|
||||
Poll for the PR to actually merge:
|
||||
|
||||
```bash
|
||||
gh pr view --json state -q .state
|
||||
```
|
||||
|
||||
Poll every 30 seconds, up to 30 minutes. Show a progress message every 2 minutes:
|
||||
"Still in the merge queue... ({X}m so far)"
|
||||
|
||||
If the PR state changes to `MERGED`: capture the merge commit SHA. Tell the user:
|
||||
"Merge queue finished — PR is merged. Took {duration}."
|
||||
|
||||
If the PR is removed from the queue (state goes back to `OPEN`): **STOP.** "The PR was removed from the merge queue — this usually means a CI check failed on the merge commit, or another PR in the queue caused a conflict. Check the GitHub merge queue page to see what happened."
|
||||
If timeout (30 min): **STOP.** "The merge queue has been processing for 30 minutes. Something might be stuck — check the GitHub Actions tab and the merge queue page."
|
||||
|
||||
### 4b: CI auto-deploy detection
|
||||
|
||||
After the PR is merged, check if a deploy workflow was triggered by the merge:
|
||||
After the PR has landed, check if a deploy workflow was triggered by the merge. Match
|
||||
on `LAND_SHA` (the merge commit captured in Step 2):
|
||||
|
||||
```bash
|
||||
gh run list --branch <base> --limit 5 --json name,status,workflowName,headSha
|
||||
```
|
||||
|
||||
Look for runs matching the merge commit SHA. If a deploy workflow is found:
|
||||
- Tell the user: "PR merged. I can see a deploy workflow ('{workflow-name}') kicked off automatically. I'll monitor it and let you know when it's done."
|
||||
Look for runs whose `headSha` matches `LAND_SHA`. If a deploy workflow is found:
|
||||
- Tell the user: "PR landed. I can see a deploy workflow ('{workflow-name}') kicked off automatically. I'll monitor it and let you know when it's done."
|
||||
|
||||
If no deploy workflow is found after merge:
|
||||
- Tell the user: "PR merged. I don't see a deploy workflow — your project might deploy a different way, or it might be a library/CLI that doesn't have a deploy step. I'll figure out the right verification in the next step."
|
||||
If no deploy workflow is found after the merge:
|
||||
- Tell the user: "PR landed. I don't see a deploy workflow — your project might deploy a different way, or it might be a library/CLI that doesn't have a deploy step. I'll figure out the right verification in the next step."
|
||||
|
||||
If `MERGE_PATH=auto` and the repo uses merge queues AND a deploy workflow exists:
|
||||
If `LAND_REGIME` is `github` or `trunk` (a merge queue) AND a deploy workflow exists:
|
||||
- Tell the user: "PR made it through the merge queue and the deploy workflow is running. Monitoring it now."
|
||||
|
||||
Record merge timestamp, duration, and merge path for the deploy report.
|
||||
Record the landing timestamp and `LAND_REGIME` for the deploy report.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -777,7 +458,7 @@ If a deploy workflow was detected, find the run triggered by the merge commit:
|
|||
gh run list --branch <base> --limit 10 --json databaseId,headSha,status,conclusion,name,workflowName
|
||||
```
|
||||
|
||||
Match by the merge commit SHA (captured in Step 4). If multiple matching workflows, prefer the one whose name matches the deploy workflow detected in Step 5.
|
||||
Match by `LAND_SHA` (the merge commit captured in Step 2). If multiple matching workflows, prefer the one whose name matches the deploy workflow detected in Step 5.
|
||||
|
||||
Poll every 30 seconds:
|
||||
```bash
|
||||
|
|
@ -899,19 +580,35 @@ If the user chose to revert at any point:
|
|||
|
||||
Tell the user: "Reverting the merge now. This will create a new commit that undoes all the changes from this PR. The previous version of your site will be restored once the revert deploys."
|
||||
|
||||
Use `LAND_SHA` (the confirmed merge commit from Step 2) as the revert target.
|
||||
|
||||
**Merge-queue / protected branches first (H8).** If `LAND_REGIME` is `github` or `trunk`,
|
||||
a direct push to the base branch is almost always blocked by branch protection, so go
|
||||
straight to a revert PR — do not attempt a direct push:
|
||||
|
||||
```bash
|
||||
git fetch origin <base>
|
||||
git checkout -b revert-pr-<NNN> origin/<base>
|
||||
git revert <LAND_SHA> --no-edit
|
||||
git push origin revert-pr-<NNN>
|
||||
gh pr create --base <base> --head revert-pr-<NNN> --title 'revert: <original PR title>' --fill
|
||||
```
|
||||
Tell the user: "This repo uses a merge queue / protected branch, so I opened a revert PR. Merge it (it can ride the queue too) to roll back."
|
||||
|
||||
**No-queue repos (`LAND_REGIME` is `none`).** Try the direct push first:
|
||||
|
||||
```bash
|
||||
git fetch origin <base>
|
||||
git checkout <base>
|
||||
git revert <merge-commit-sha> --no-edit
|
||||
git revert <LAND_SHA> --no-edit
|
||||
git push origin <base>
|
||||
```
|
||||
|
||||
If the revert has conflicts: "The revert has merge conflicts — this can happen if other changes landed on {base} after your merge. You'll need to resolve the conflicts manually. The merge commit SHA is `<sha>` — run `git revert <sha>` to try again."
|
||||
If the revert has conflicts: "The revert has merge conflicts — this can happen if other changes landed on {base} after your merge. You'll need to resolve them manually. The merge commit SHA is `<LAND_SHA>` — run `git revert <LAND_SHA>` to try again."
|
||||
|
||||
If the base branch has push protections: "This repo has branch protections, so I can't push the revert directly. I'll create a revert PR instead — merge it to roll back."
|
||||
Then create a revert PR: `gh pr create --title 'revert: <original PR title>'`
|
||||
If the direct push is rejected by branch protection: fall back to the revert-PR flow above.
|
||||
|
||||
After a successful revert: Tell the user "Revert pushed to {base}. The deploy should roll back automatically once CI passes. Keep an eye on the site to confirm." Note the revert commit SHA and continue to Step 9 with status REVERTED.
|
||||
After a successful revert (pushed or PR merged): Tell the user "Revert is in — the deploy should roll back automatically once CI passes. Keep an eye on the site to confirm." Note the revert commit SHA and continue to Step 9 with status REVERTED.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -930,15 +627,14 @@ LAND & DEPLOY REPORT
|
|||
═════════════════════
|
||||
PR: #<number> — <title>
|
||||
Branch: <head-branch> → <base-branch>
|
||||
Merged: <timestamp> (<merge method>)
|
||||
Merge SHA: <sha>
|
||||
Merge path: <auto-merge / direct / merge queue>
|
||||
Landed: <timestamp>
|
||||
Merge SHA: <LAND_SHA>
|
||||
Merge regime: <none / github / trunk> (landing handled by /land)
|
||||
First run: <yes (dry-run validated) / no (previously confirmed)>
|
||||
|
||||
Timing:
|
||||
Dry-run: <duration or "skipped (confirmed)">
|
||||
CI wait: <duration>
|
||||
Queue: <duration or "direct merge">
|
||||
Land: <duration of /land — CI wait + queue + merge>
|
||||
Deploy: <duration or "no workflow detected">
|
||||
Staging: <duration or "skipped">
|
||||
Canary: <duration or "skipped">
|
||||
|
|
@ -971,7 +667,7 @@ mkdir -p ~/.gstack/projects/$SLUG
|
|||
|
||||
Write a JSONL entry with timing data:
|
||||
```json
|
||||
{"skill":"land-and-deploy","timestamp":"<ISO>","status":"<SUCCESS/REVERTED>","pr":<number>,"merge_sha":"<sha>","merge_path":"<auto/direct/queue>","first_run":<true/false>,"deploy_status":"<HEALTHY/DEGRADED/SKIPPED>","staging_status":"<VERIFIED/SKIPPED>","review_status":"<CURRENT/STALE/NOT_RUN/INLINE_FIX>","ci_wait_s":<N>,"queue_s":<N>,"deploy_s":<N>,"staging_s":<N>,"canary_s":<N>,"total_s":<N>}
|
||||
{"skill":"land-and-deploy","timestamp":"<ISO>","status":"<SUCCESS/REVERTED>","pr":<number>,"merge_sha":"<LAND_SHA>","merge_regime":"<none/github/trunk>","first_run":<true/false>,"deploy_status":"<HEALTHY/DEGRADED/SKIPPED>","staging_status":"<VERIFIED/SKIPPED>","review_status":"<CURRENT/STALE/NOT_RUN/INLINE_FIX>","land_s":<N>,"deploy_s":<N>,"staging_s":<N>,"canary_s":<N>,"total_s":<N>}
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1000,9 +696,10 @@ Then suggest relevant follow-ups:
|
|||
- **Narrate the journey.** The user should always know: what just happened, what's happening now, and what's about to happen next. No silent gaps between steps.
|
||||
- **Auto-detect everything.** PR number, merge method, deploy strategy, project type, merge queues, staging environments. Only ask when information genuinely can't be inferred.
|
||||
- **Poll with backoff.** Don't hammer GitHub API. 30-second intervals for CI/deploy, with reasonable timeouts.
|
||||
- **Revert is always an option.** At every failure point, offer revert as an escape hatch. Explain what reverting does in plain English.
|
||||
- **Revert is always an option.** At every failure point, offer revert as an escape hatch (Step 8 uses `LAND_SHA` and goes PR-first on queue/protected branches). Explain what reverting does in plain English.
|
||||
- **Single-pass verification, not continuous monitoring.** `/land-and-deploy` checks once. `/canary` does the extended monitoring loop.
|
||||
- **Clean up.** Delete the feature branch after merge (via `--delete-branch`).
|
||||
- **Branch cleanup belongs to `/land`.** `/land` deletes the feature branch on the no-queue/GitHub paths; on the trunk path, Trunk owns branch cleanup. Don't delete branches here.
|
||||
- **The merge lives in `/land`.** This skill never calls `gh pr merge` itself — it composes `/land` (Step 2) and consumes the landing handoff. Keep merge logic in one place.
|
||||
- **First run = teacher mode.** Walk the user through everything. Explain what each check does and why it matters. Show them their infrastructure. Let them confirm before proceeding. Build trust through transparency.
|
||||
- **Subsequent runs = efficient mode.** Brief status updates, no re-explanations. The user already trusts the tool — just do the job and report results.
|
||||
- **The goal is: first-timers think "wow, this is thorough — I trust it." Repeat users think "that was fast — it just works."**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,558 @@
|
|||
---
|
||||
name: land
|
||||
preamble-tier: 4
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Land a PR through the right merge regime: pre-flight, CI wait, VERSION-drift
|
||||
check, pre-merge readiness gate, then merge via no-queue, GitHub native merge
|
||||
queue, or trunk.io merge queue. This is the "land" half of /land-and-deploy,
|
||||
usable on its own when you want to merge but not deploy. Use when: "land",
|
||||
"land the pr", "land it", "merge", "merge the pr", "merge it", "get it merged".
|
||||
For deploy + canary verification after landing, use /land-and-deploy. (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
sensitive: true
|
||||
triggers:
|
||||
- land the pr
|
||||
- land it
|
||||
- merge the pr
|
||||
- merge it
|
||||
- get it merged
|
||||
---
|
||||
|
||||
{{PREAMBLE}}
|
||||
|
||||
{{BASE_BRANCH_DETECT}}
|
||||
|
||||
**If the platform detected above is GitLab or unknown:** STOP with: "Merge-queue landing through /land currently supports GitHub only. On GitLab, run `/ship` to create the MR, then merge it (or add it to a merge train) from the GitLab web UI." Do not proceed. GitLab merge trains are a future enhancement.
|
||||
|
||||
# /land — Land a PR through the right merge regime
|
||||
|
||||
You are a **Release Engineer** who has merged to protected branches thousands of times. You know the merge that breaks the base branch is the one that skipped a check, and the merge that sits silently in a queue is the one nobody told you got ejected. Your job: verify readiness honestly, merge the way THIS repo actually merges (no queue, GitHub's native queue, or trunk.io's queue), and confirm the change truly landed before you say "done."
|
||||
|
||||
This skill lands a PR. It does not deploy. If the user also wants deploy + canary verification, that is `/land-and-deploy` (which runs this skill first, then deploys).
|
||||
|
||||
## User-invocable
|
||||
When the user types `/land`, run this skill.
|
||||
|
||||
## Arguments
|
||||
- `/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
|
||||
|
||||
This is a **mostly automated** workflow. The user said `/land`, which means DO IT — but verify readiness first, because a merge to a protected base branch is irreversible without a revert.
|
||||
|
||||
**Always stop for:**
|
||||
- **Pre-merge readiness gate (Step 3.5)** — reviews, tests, docs, PR accuracy before the merge (unless `--fast` and there are zero blockers)
|
||||
- GitHub CLI not authenticated
|
||||
- No PR found for this branch
|
||||
- CI failures or merge conflicts
|
||||
- Permission denied on merge
|
||||
- Merge-queue ejection (the queue rejected the PR)
|
||||
- Landing could not be confirmed (no merge SHA)
|
||||
|
||||
**Never stop for:**
|
||||
- Choosing the merge regime (config → auto-detect → ask once → persist)
|
||||
- Timeout warnings on queue waits (warn and surface, don't silently hang)
|
||||
|
||||
## 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 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.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
Tell the user: "Let me make sure GitHub is connected and find your PR."
|
||||
|
||||
1. Check GitHub CLI authentication:
|
||||
```bash
|
||||
gh auth status
|
||||
```
|
||||
If not authenticated, **STOP**: "I need GitHub CLI access to land your PR. Run `gh auth login`, then try `/land` again."
|
||||
|
||||
2. Parse arguments. If the user passed `#NNN`, use that PR number. If they passed `--fast`, remember that for Step 3.5.
|
||||
|
||||
3. If no PR number was given, detect it from the current branch:
|
||||
```bash
|
||||
gh pr view --json number,state,title,url,mergeStateStatus,mergeable,baseRefName,headRefName
|
||||
```
|
||||
|
||||
4. Tell the user what you found: "Found PR #NNN — '{title}' ({head} → {base})."
|
||||
|
||||
5. Validate the PR state:
|
||||
- No PR exists: **STOP.** "No PR found for this branch. Run `/ship` first to create one, then `/land`."
|
||||
- `state` is `MERGED`: "This PR is already merged — nothing to land." (If they came from `/land-and-deploy`, the parent will pick up the existing landing state.)
|
||||
- `state` is `CLOSED`: "This PR was closed without merging. Reopen it on GitHub, then try again."
|
||||
- `state` is `OPEN`: continue.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Pre-merge checks
|
||||
|
||||
Tell the user: "Checking CI status and merge readiness..."
|
||||
|
||||
```bash
|
||||
gh pr checks --json name,state,status,conclusion
|
||||
```
|
||||
|
||||
Parse:
|
||||
1. Any required check **FAILING**: **STOP.** "CI is failing: {list}. Fix these before landing — I won't merge code that hasn't passed CI."
|
||||
2. Required checks **PENDING**: "CI is still running. I'll wait." Proceed to Step 3.
|
||||
3. All pass (or no required checks): "CI passed." Skip Step 3, go to Step 3.4.
|
||||
|
||||
Check for merge conflicts:
|
||||
```bash
|
||||
gh pr view --json mergeable -q .mergeable
|
||||
```
|
||||
If `CONFLICTING`: **STOP.** "This PR conflicts with {base}. Resolve the conflicts and push, then run `/land` again."
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Wait for CI (if pending)
|
||||
|
||||
If required checks are still pending, wait with a 15-minute timeout:
|
||||
|
||||
```bash
|
||||
gh pr checks --watch --fail-fast
|
||||
```
|
||||
|
||||
Record the CI wait time.
|
||||
|
||||
- CI passes: "CI passed after {duration}. Moving to readiness checks." Continue to Step 3.4.
|
||||
- CI fails: **STOP.** "CI failed: {failures}. This needs to pass before I can merge."
|
||||
- Timeout (15 min): **STOP.** "CI has been running over 15 minutes — that's unusual. Check the GitHub Actions tab."
|
||||
|
||||
---
|
||||
|
||||
## Step 3.4: VERSION drift detection (workspace-aware ship)
|
||||
|
||||
Before gathering readiness evidence, verify the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
|
||||
QUEUE_JSON=$(bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
1. If `OFFLINE=true` or the util fails: print `⚠ VERSION drift check unavailable (util offline) — proceeding with PR version v<BRANCH_VERSION>`. Continue. CI's version-gate job is the backstop.
|
||||
|
||||
2. If `BRANCH_VERSION` is already `>=` `NEXT_SLOT`: no drift. Continue.
|
||||
|
||||
3. If drift is detected (`BRANCH_VERSION < NEXT_SLOT`): **STOP** and print exactly:
|
||||
```
|
||||
⚠ VERSION drift detected.
|
||||
This PR claims: v<BRANCH_VERSION>
|
||||
Next free slot: v<NEXT_SLOT> (queue moved since last /ship)
|
||||
|
||||
Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED
|
||||
branch will detect the drift and rewrite VERSION + CHANGELOG header + PR title
|
||||
atomically. Do NOT merge from here — the landed PR would overwrite the other
|
||||
branch's CHANGELOG entry or land with a duplicate version header.
|
||||
```
|
||||
Exit non-zero. Do NOT auto-bump from `/land` — rerunning `/ship` is the clean path.
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Pre-merge readiness gate
|
||||
|
||||
**This is the critical safety check before an irreversible merge.** Gather ALL evidence, build a readiness report, and get explicit confirmation before proceeding.
|
||||
|
||||
Tell the user: "CI is green. Now I'm running readiness checks — the last gate before I merge. I'm checking code reviews, tests, documentation, and PR accuracy."
|
||||
|
||||
Collect evidence below. Track warnings (yellow) and blockers (red).
|
||||
|
||||
### 3.5a: Review staleness check
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-review-read 2>/dev/null
|
||||
```
|
||||
|
||||
For each review skill (plan-eng-review, plan-ceo-review, plan-design-review, design-review-lite, codex-review, review, adversarial-review, codex-plan-review):
|
||||
|
||||
1. Find the most recent entry within the last 7 days.
|
||||
2. Extract its `commit` field.
|
||||
3. Compare against HEAD: `git rev-list --count STORED_COMMIT..HEAD`
|
||||
|
||||
**Staleness rules:**
|
||||
- 0 commits since review → CURRENT
|
||||
- 1-3 commits → RECENT (yellow if those commits touch code, not just docs)
|
||||
- 4+ commits → STALE (red — review may not reflect current code)
|
||||
- No review found → NOT RUN
|
||||
|
||||
**Critical check:** Look at what changed AFTER the last review:
|
||||
```bash
|
||||
git log --oneline STORED_COMMIT..HEAD
|
||||
```
|
||||
If any post-review commit says "fix", "refactor", "rewrite", "overhaul", or touches more than 5 files — flag as **STALE (significant changes since review)**.
|
||||
|
||||
Note `codex-review` (adversarial) as an extra confidence signal if CURRENT; informational if not run.
|
||||
|
||||
### 3.5a-bis: Inline review offer
|
||||
|
||||
If engineering review is STALE (4+ commits) or NOT RUN, offer a quick review before proceeding (skip this sub-step entirely if the review is CURRENT, or if `--fast` was passed).
|
||||
|
||||
Use AskUserQuestion:
|
||||
- **Re-ground:** "I noticed {the code review is stale / no code review has been run} on this branch. Since this is about to land on {base}, I'd like a quick safety check on the diff first."
|
||||
- **RECOMMENDATION:** Choose A for a quick safety check. Choose B for the full review. Choose C only if you're confident.
|
||||
- A) Run a quick review (~2 min) — scan the diff for SQL safety, race conditions, security gaps (Completeness: 7/10)
|
||||
- B) Stop and run a full `/review` first — deeper analysis (Completeness: 10/10)
|
||||
- C) Skip the review — I've reviewed this myself (Completeness: 3/10)
|
||||
|
||||
**If A:** Read `~/.claude/skills/gstack/review/checklist.md` and apply each item to the current diff. Auto-fix trivial issues (whitespace, imports). For critical findings, ask the user.
|
||||
|
||||
**If any code changes are made during the quick review:** Commit the fixes, then **STOP** and tell the user: "I found and fixed a few issues during the review. The fixes are committed — run `/land` again to pick them up." Do NOT proceed to merge, and do NOT write any landing state — the branch changed after CI, so CI must re-run.
|
||||
|
||||
**If no issues found:** "Review checklist passed — no issues in the diff."
|
||||
|
||||
**If B:** **STOP.** "Run `/review` for a thorough pre-landing review, then `/land` again."
|
||||
|
||||
**If C:** "Understood — skipping review." Continue. Log the choice to skip.
|
||||
|
||||
### 3.5b: Test results
|
||||
|
||||
**Free tests — run them now.** Read CLAUDE.md for the project's test command. If not specified, use `bun test`. Run it, capture exit code and output.
|
||||
|
||||
```bash
|
||||
bun test 2>&1 | tail -10
|
||||
```
|
||||
|
||||
If tests fail: **BLOCKER.** Cannot merge with failing tests.
|
||||
|
||||
**E2E / LLM-judge — check recent results:**
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
ls -t ~/.gstack-dev/evals/*-e2e-*-$(date +%Y-%m-%d)*.json 2>/dev/null | head -20
|
||||
ls -t ~/.gstack-dev/evals/*-llm-judge-*-$(date +%Y-%m-%d)*.json 2>/dev/null | head -5
|
||||
```
|
||||
|
||||
Parse pass/fail for any of today's runs. No E2E today → **WARNING.** Failures present → **WARNING** (list them).
|
||||
|
||||
### 3.5c: PR body accuracy check
|
||||
|
||||
```bash
|
||||
gh pr view --json body -q .body
|
||||
git log --oneline $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)..HEAD | head -20
|
||||
```
|
||||
|
||||
Compare the PR body against the actual commits: missing features, stale descriptions, wrong version. If stale or incomplete: **WARNING — PR body may not reflect current changes.**
|
||||
|
||||
### 3.5d: Document-release check
|
||||
|
||||
```bash
|
||||
git diff --name-only $(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)...HEAD -- README.md CHANGELOG.md ARCHITECTURE.md CONTRIBUTING.md CLAUDE.md VERSION
|
||||
```
|
||||
|
||||
If CHANGELOG.md and VERSION were NOT modified and the diff includes new features (new files, commands, skills): **WARNING — /document-release likely not run.** If only docs changed (no code): skip this check.
|
||||
|
||||
### 3.5e: Readiness report and confirmation
|
||||
|
||||
Build the readiness report:
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════╗
|
||||
║ PRE-MERGE READINESS REPORT ║
|
||||
╠══════════════════════════════════════════════════════════╣
|
||||
║ PR: #NNN — title ║
|
||||
║ Branch: feature → {base} ║
|
||||
║ Merge regime: none / github / trunk ║
|
||||
║ ║
|
||||
║ REVIEWS ║
|
||||
║ ├─ Eng Review: CURRENT / STALE (N commits) / — ║
|
||||
║ ├─ CEO Review: CURRENT / — (optional) ║
|
||||
║ ├─ Design Review: CURRENT / — (optional) ║
|
||||
║ └─ Codex Review: CURRENT / — (optional) ║
|
||||
║ ║
|
||||
║ TESTS ║
|
||||
║ ├─ Free tests: PASS / FAIL (blocker) ║
|
||||
║ ├─ E2E tests: N/N pass (Xm ago) / NOT RUN ║
|
||||
║ └─ LLM evals: PASS / NOT RUN ║
|
||||
║ ║
|
||||
║ DOCUMENTATION ║
|
||||
║ ├─ CHANGELOG: Updated / NOT UPDATED (warning) ║
|
||||
║ ├─ VERSION: X.Y.Z.W / NOT BUMPED (warning) ║
|
||||
║ └─ PR body: Current / STALE (warning) ║
|
||||
║ ║
|
||||
║ WARNINGS: N | BLOCKERS: N ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
**`--fast` handling:**
|
||||
- If `--fast` AND there are **zero blockers**: print the report, then proceed to Step 4 WITHOUT asking. Print "Fast mode: no blockers, landing without the confirmation prompt."
|
||||
- If there are any **blockers** (failing free tests): `--fast` does NOT apply — list the blockers and recommend B below. Never auto-proceed past a blocker.
|
||||
- Without `--fast`: always ask.
|
||||
|
||||
Use AskUserQuestion:
|
||||
- **Re-ground:** "Ready to merge PR #NNN — '{title}' into {base} via the {regime} regime. Here's what I found." Show the report.
|
||||
- If green: "All checks passed. Ready to merge."
|
||||
- If warnings: list each in plain English ("Eng review was 6 commits ago — code changed since then").
|
||||
- If blockers: "I found issues that must be fixed first: {list}"
|
||||
- **RECOMMENDATION:** A if green; B if significant warnings; C only if the user accepts the risk.
|
||||
- A) Merge it — everything looks good (Completeness: 10/10)
|
||||
- B) Hold off — I want to fix the warnings first (Completeness: 10/10)
|
||||
- C) Merge anyway — I understand the warnings (Completeness: 3/10)
|
||||
|
||||
If B: **STOP** with specific next steps (run `/review`, run E2E, run `/document-release`, or fix the PR body).
|
||||
If A or C: "Merging now." Continue to Step 4.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Merge through the right regime
|
||||
|
||||
This is the heart of `/land`. The merge **command** depends on the regime, but the "did it land" **signal** is uniform — so a single helper, `bin/gstack-merge`, owns detection, submission, and the landing poll.
|
||||
|
||||
### 4.1: Resolve the merge regime
|
||||
|
||||
Resolution order (platform-agnostic rule — the project owns its config, gstack reads it):
|
||||
|
||||
1. **Explicit config** — read the `## Merge Configuration` section of CLAUDE.md for a `Merge queue: none|github|trunk` line.
|
||||
2. **Auto-detect** — if no config line, ask the helper:
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-merge detect --base <base> --json
|
||||
```
|
||||
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)."
|
||||
|
||||
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
|
||||
~/.claude/skills/gstack/bin/gstack-merge submit --regime <regime> --pr <NNN> --base <base>
|
||||
```
|
||||
|
||||
What the helper does per regime:
|
||||
- **none** → `gh pr merge <pr> --squash --delete-branch`
|
||||
- **github** → `gh pr merge <pr> --auto --delete-branch` (GitHub auto-merge / native queue; falls back to a direct squash if `--auto` is not enabled)
|
||||
- **trunk** → **comment-first**: `gh pr comment <pr> --body "/trunk merge"` (zero new auth — works the moment Trunk's GitHub App is installed), then the `trunk` CLI if installed, then the Trunk REST API if `$TRUNK_API_TOKEN` is set. NEVER `gh pr merge`, NEVER `--delete-branch` — Trunk owns the merge and branch cleanup.
|
||||
|
||||
If the user wants priority on a trunk queue, pass `--priority <urgent|high|medium|low|lowest>`.
|
||||
|
||||
### 4.2a: Post-failure PR-state check
|
||||
|
||||
**Universal invariant:** after ANY non-zero exit from `gh pr merge` (the `none`/`github`
|
||||
submit paths), query authoritative PR state before retrying or stopping. Do NOT retry
|
||||
`gh pr merge`. Related: cli/cli#3442, cli/cli#13380. (For the `trunk` path, the same
|
||||
no-blind-retry rule applies to `submit` per H4 — never resubmit a failed `/trunk merge`;
|
||||
check status first.)
|
||||
|
||||
```bash
|
||||
gh pr view --json state,mergeCommit,mergedAt,mergedBy
|
||||
```
|
||||
|
||||
**If `state == "MERGED"`:**
|
||||
|
||||
The server-side merge succeeded (it may have completed before the local cleanup phase failed, or a concurrent merge landed). Tell the user: "PR is merged on GitHub." (Do NOT say "the merge succeeded" — this also covers the concurrent-merge case.)
|
||||
|
||||
Capture the merge SHA:
|
||||
```bash
|
||||
gh pr view --json mergeCommit -q .mergeCommit.oid
|
||||
```
|
||||
|
||||
Worktree cleanup — non-destructive, candidate-based:
|
||||
```bash
|
||||
git worktree list --porcelain
|
||||
```
|
||||
A worktree is a stale candidate if (a) it is checked out on the base branch, AND (b) it is not the user's primary working tree, AND (c) `git status --porcelain` inside it is empty.
|
||||
- For each clean candidate: OFFER to remove it ("There's a stale worktree at `<path>` on `<branch>` with no uncommitted work. Remove it?"). Remove only on confirmation (`git worktree remove <path> && git worktree prune`).
|
||||
- If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
|
||||
- Do NOT use `--force`. Do NOT remove the user's primary working tree.
|
||||
|
||||
Then continue to the landing confirmation (Step 5) — `write-state` will confirm the SHA.
|
||||
|
||||
**If `state == "OPEN"`:**
|
||||
|
||||
Check whether auto-merge / a queue is active:
|
||||
```bash
|
||||
gh pr view --json autoMergeRequest -q .autoMergeRequest
|
||||
```
|
||||
- If non-null: auto-merge is enabled or merge queue is in use. The open state is expected — continue to 4.3's wait.
|
||||
- If null: genuine failure. Surface both the `submit` stderr AND the current PR open state, then **STOP**.
|
||||
|
||||
**If `state == "CLOSED"`:** the PR was closed without merging. **STOP.**
|
||||
|
||||
**Hard rule: never call `gh pr merge` a second time** after a non-zero exit. Server state is authoritative.
|
||||
|
||||
### 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). 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.** (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
|
||||
{{SLUG_EVAL}}
|
||||
~/.claude/skills/gstack/bin/gstack-merge write-state --regime <regime> --pr <NNN> --base <base> --slug "$SLUG"
|
||||
```
|
||||
|
||||
The helper polls until the PR is `MERGED` with a non-null merge SHA (the SHA can lag the state flip on squash/queue merges), verifies the commit is actually on `origin/<base>`, then atomically writes `~/.gstack/projects/$SLUG/last-land.json`:
|
||||
|
||||
```json
|
||||
{"schema_version":1,"pr":NNN,"sha":"<oid>","headRefOid":"<oid>","base":"<branch>","head_branch":"<branch>","repo":"owner/name","regime":"<regime>","ts":"<ISO>"}
|
||||
```
|
||||
|
||||
and prints a human echo: `LANDED: pr=#NNN sha=<oid> regime=<regime> base=<branch>`.
|
||||
|
||||
- **If `write-state` exits non-zero:** landing could not be confirmed. **STOP** and do NOT report success: "The PR shows as merged but I couldn't confirm the commit on {base} / capture a merge SHA. Don't deploy off this until you verify on GitHub."
|
||||
- **If it succeeds:** the PR has truly landed and the handoff file is written.
|
||||
|
||||
> When this skill is composed by `/land-and-deploy`, that skill reads `last-land.json` after this step (validating it is for this exact PR + repo and recent) and uses the SHA for deploy matching and revert.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Summary
|
||||
|
||||
### 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
|
||||
═══════════
|
||||
PR: #<number> — <title>
|
||||
Branch: <head> → <base>
|
||||
Regime: <none / github / trunk>
|
||||
Merged: <timestamp>
|
||||
Merge SHA: <sha>
|
||||
CI: <PASSED / SKIPPED>
|
||||
Reviews: <Eng: CURRENT/STALE/NOT RUN; inline fix: yes(N)/no/skipped>
|
||||
|
||||
VERDICT: LANDED
|
||||
```
|
||||
|
||||
Then suggest the natural next step: "Want to deploy and verify this in production? Run `/land-and-deploy` — it'll pick up this landing and take it through deploy + canary." (Skip this suggestion when `/land` was invoked by `/land-and-deploy` — it already continues to deploy.)
|
||||
|
||||
---
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **Never force push.** Use `gh pr merge` / the queue — never a manual push to the base branch.
|
||||
- **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`.
|
||||
- **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.
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
// lib/merge.ts — pure merge-regime logic for /land and /land-and-deploy.
|
||||
//
|
||||
// This module is the single source of truth for the four risky merge
|
||||
// operations, kept pure (no I/O) so they can be unit-tested with fixtures:
|
||||
//
|
||||
// detectRegime — none | github | trunk, from gh/check/config signals
|
||||
// planSubmit — ordered list of submit commands per regime (trunk = comment-first)
|
||||
// classifyLand — landed | ejected | pending | closed, from PR state + checks
|
||||
// buildLandState / validateConsume — the last-land.json handoff contract
|
||||
//
|
||||
// The CLI wrapper (bin/gstack-merge) gathers the live data via gh/git/fs and
|
||||
// calls these functions. Keeping logic here means an E2E that inspects a
|
||||
// command tests prompt text, but the unit tests here test behavior.
|
||||
|
||||
export type Regime = 'none' | 'github' | 'trunk';
|
||||
|
||||
export const LAND_STATE_SCHEMA_VERSION = 1;
|
||||
|
||||
// --- Regime detection ---------------------------------------------------
|
||||
|
||||
export interface PrCheck {
|
||||
/** Check name, e.g. "Trunk Merge Queue (main)". */
|
||||
name?: string;
|
||||
/** gh exposes `state` (e.g. SUCCESS) and/or `bucket` (pass/fail/pending). */
|
||||
state?: string;
|
||||
bucket?: string;
|
||||
}
|
||||
|
||||
export interface DetectInput {
|
||||
/** Base branch the PR targets, e.g. "main". */
|
||||
base: string;
|
||||
/** Output of `gh pr checks --json name,state,bucket`. */
|
||||
checks: PrCheck[];
|
||||
/** Contents of .trunk/trunk.yaml if present, else null. */
|
||||
trunkYaml: string | null;
|
||||
/** Explicit "Merge queue: X" from CLAUDE.md ## Merge Configuration, else null. */
|
||||
configRegime?: string | null;
|
||||
/** True when branch protection on `base` has a GitHub-native merge queue. */
|
||||
githubMergeQueue?: boolean;
|
||||
}
|
||||
|
||||
export interface DetectResult {
|
||||
regime: Regime;
|
||||
/** How the regime was decided — for honest narration. */
|
||||
source: 'config' | 'trunk-status-check' | 'trunk-yaml' | 'github-branch-protection' | 'default';
|
||||
}
|
||||
|
||||
const VALID_REGIMES: Regime[] = ['none', 'github', 'trunk'];
|
||||
|
||||
/** Name of the GitHub status check Trunk posts on PRs, e.g. "Trunk Merge Queue (main)". */
|
||||
export function trunkQueueCheckName(base: string): string {
|
||||
return `Trunk Merge Queue (${base})`;
|
||||
}
|
||||
|
||||
/** Match any "Trunk Merge Queue (<branch>)" check regardless of branch. */
|
||||
export function isTrunkQueueCheck(name: string | undefined): boolean {
|
||||
return !!name && /^Trunk Merge Queue \(.+\)$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the merge regime. Precedence:
|
||||
* 1. explicit config key (the project owns its config)
|
||||
* 2. live Trunk status check on the PR (the authoritative live signal)
|
||||
* 3. .trunk/trunk.yaml `merge:` section (secondary — NOT `.trunk/` presence
|
||||
* alone, which `trunk check` also creates)
|
||||
* 4. GitHub-native merge queue from branch protection
|
||||
* 5. none
|
||||
*/
|
||||
export function detectRegime(input: DetectInput): DetectResult {
|
||||
const cfg = (input.configRegime || '').trim().toLowerCase();
|
||||
if (VALID_REGIMES.includes(cfg as Regime)) {
|
||||
return { regime: cfg as Regime, source: 'config' };
|
||||
}
|
||||
|
||||
if (input.checks.some((c) => isTrunkQueueCheck(c.name))) {
|
||||
return { regime: 'trunk', source: 'trunk-status-check' };
|
||||
}
|
||||
|
||||
// Secondary: a `merge:` section in .trunk/trunk.yaml means merge-queue is
|
||||
// configured for this repo (distinct from a bare .trunk/ that only carries
|
||||
// `trunk check` linter config).
|
||||
if (input.trunkYaml && /^\s*merge\s*:/m.test(input.trunkYaml)) {
|
||||
return { regime: 'trunk', source: 'trunk-yaml' };
|
||||
}
|
||||
|
||||
if (input.githubMergeQueue) {
|
||||
return { regime: 'github', source: 'github-branch-protection' };
|
||||
}
|
||||
|
||||
return { regime: 'none', source: 'default' };
|
||||
}
|
||||
|
||||
// --- Submit planning ----------------------------------------------------
|
||||
|
||||
export interface SubmitCandidate {
|
||||
/** Program to run. */
|
||||
cmd: string;
|
||||
/** Args (pr number substituted). */
|
||||
args: string[];
|
||||
/** Human description for narration. */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface SubmitPlan {
|
||||
regime: Regime;
|
||||
/** Candidates to try in order; first that runs cleanly wins. */
|
||||
candidates: SubmitCandidate[];
|
||||
/** Whether `--delete-branch` is owned by us (false for trunk — Trunk owns it). */
|
||||
deleteBranch: boolean;
|
||||
}
|
||||
|
||||
export interface SubmitOpts {
|
||||
/** `trunk` CLI is installed + on PATH. */
|
||||
trunkCliAvailable?: boolean;
|
||||
/** $TRUNK_API_TOKEN is set (enables the REST fallback). */
|
||||
trunkToken?: boolean;
|
||||
/** Optional priority word for trunk (urgent|high|medium|low|lowest). */
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered submit plan for a regime.
|
||||
*
|
||||
* trunk is **comment-first**: `gh pr comment "/trunk merge"` needs zero new
|
||||
* auth (gh is already required and Trunk's "GitHub commands" toggle is
|
||||
* default-ON), so it works the moment Trunk's GitHub App is installed. The
|
||||
* trunk CLI and REST are opportunistic upgrades.
|
||||
*/
|
||||
export function planSubmit(regime: Regime, pr: number, opts: SubmitOpts = {}): SubmitPlan {
|
||||
const prRef = String(pr);
|
||||
if (regime === 'none') {
|
||||
return {
|
||||
regime,
|
||||
deleteBranch: true,
|
||||
candidates: [
|
||||
{ cmd: 'gh', args: ['pr', 'merge', prRef, '--squash', '--delete-branch'], desc: 'direct squash merge' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (regime === 'github') {
|
||||
return {
|
||||
regime,
|
||||
deleteBranch: true,
|
||||
candidates: [
|
||||
{ cmd: 'gh', args: ['pr', 'merge', prRef, '--auto', '--delete-branch'], desc: 'GitHub auto-merge / merge queue' },
|
||||
// If --auto is not enabled on the repo, fall back to a direct squash.
|
||||
{ cmd: 'gh', args: ['pr', 'merge', prRef, '--squash', '--delete-branch'], desc: 'direct squash merge (auto unavailable)' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// trunk — comment-first, then CLI, then REST. NEVER `gh pr merge`, NEVER
|
||||
// --delete-branch (Trunk owns the merge and branch cleanup).
|
||||
const commentBody = opts.priority ? `/trunk merge --priority=${opts.priority}` : '/trunk merge';
|
||||
const candidates: SubmitCandidate[] = [
|
||||
{ cmd: 'gh', args: ['pr', 'comment', prRef, '--body', commentBody], desc: 'enqueue via GitHub comment (/trunk merge)' },
|
||||
];
|
||||
if (opts.trunkCliAvailable) {
|
||||
const cliArgs = ['merge', prRef];
|
||||
if (opts.priority) cliArgs.push('--priority', opts.priority);
|
||||
candidates.push({ cmd: 'trunk', args: cliArgs, desc: 'enqueue via trunk CLI' });
|
||||
}
|
||||
if (opts.trunkToken) {
|
||||
// REST fallback is executed specially by the CLI wrapper (curl with token);
|
||||
// represented here so callers/tests see the full ordered chain.
|
||||
candidates.push({ cmd: 'trunk-rest', args: [prRef], desc: 'enqueue via Trunk REST API' });
|
||||
}
|
||||
return { regime, deleteBranch: false, candidates };
|
||||
}
|
||||
|
||||
// --- Landing classification ---------------------------------------------
|
||||
|
||||
export type LandStatus = 'landed' | 'ejected' | 'pending' | 'closed';
|
||||
|
||||
export interface ClassifyInput {
|
||||
/** PR state: OPEN | MERGED | CLOSED. */
|
||||
state: string;
|
||||
/** `gh pr view --json mergeCommit -q .mergeCommit.oid`, null if absent. */
|
||||
mergeCommitOid: string | null;
|
||||
/** True if `git branch -r --contains <headRefOid>` shows the base branch. */
|
||||
baseContainsHead: boolean;
|
||||
/** The Trunk/GitHub merge-queue check on the PR, if present. */
|
||||
queueCheck?: { name?: string; state?: string; bucket?: string } | null;
|
||||
/** Whether GitHub auto-merge is enabled (autoMergeRequest non-null). */
|
||||
autoMergeEnabled?: boolean;
|
||||
}
|
||||
|
||||
const EJECTED_STATES = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'CANCELED', 'FAIL']);
|
||||
|
||||
/**
|
||||
* Decide whether a PR has landed. Uniform across all three regimes:
|
||||
*
|
||||
* landed = MERGED AND (mergeCommit.oid non-null OR base contains the head)
|
||||
* pending = MERGED but SHA not yet visible (squash/rebase lag), or still in queue
|
||||
* ejected = queue check failed/cancelled while PR is still OPEN
|
||||
* closed = CLOSED without merging
|
||||
*
|
||||
* The (oid OR baseContainsHead) guard handles rebase-merge repos where
|
||||
* mergeCommit.oid stays null — H3.
|
||||
*/
|
||||
export function classifyLand(input: ClassifyInput): { status: LandStatus; reason: string } {
|
||||
const state = (input.state || '').toUpperCase();
|
||||
|
||||
if (state === 'MERGED') {
|
||||
if (input.mergeCommitOid || input.baseContainsHead) {
|
||||
return { status: 'landed', reason: 'PR is MERGED and the commit is on the base branch' };
|
||||
}
|
||||
return { status: 'pending', reason: 'PR is MERGED but the merge SHA is not visible yet (squash/rebase lag)' };
|
||||
}
|
||||
|
||||
if (state === 'CLOSED') {
|
||||
return { status: 'closed', reason: 'PR was closed without merging' };
|
||||
}
|
||||
|
||||
// state OPEN
|
||||
const cs = (input.queueCheck?.state || input.queueCheck?.bucket || '').toUpperCase();
|
||||
if (input.queueCheck && EJECTED_STATES.has(cs)) {
|
||||
return { status: 'ejected', reason: `merge-queue check reported ${cs}` };
|
||||
}
|
||||
|
||||
return { status: 'pending', reason: 'PR is still open (in queue or merge pending)' };
|
||||
}
|
||||
|
||||
// --- Handoff state (last-land.json) -------------------------------------
|
||||
|
||||
export interface LandState {
|
||||
schema_version: number;
|
||||
pr: number;
|
||||
sha: string;
|
||||
headRefOid: string;
|
||||
base: string;
|
||||
head_branch: string;
|
||||
repo: string; // owner/name
|
||||
regime: Regime;
|
||||
ts: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface BuildLandStateInput {
|
||||
pr: number;
|
||||
sha: string;
|
||||
headRefOid: string;
|
||||
base: string;
|
||||
head_branch: string;
|
||||
repo: string;
|
||||
regime: Regime;
|
||||
/** ISO timestamp — injected so this stays pure/deterministic in tests. */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
/** Assemble a validated LandState. Throws if the landing SHA is missing. */
|
||||
export function buildLandState(input: BuildLandStateInput): LandState {
|
||||
if (!input.sha) {
|
||||
throw new Error('refusing to write last-land.json with an empty merge SHA — landing not confirmed');
|
||||
}
|
||||
return {
|
||||
schema_version: LAND_STATE_SCHEMA_VERSION,
|
||||
pr: input.pr,
|
||||
sha: input.sha,
|
||||
headRefOid: input.headRefOid,
|
||||
base: input.base,
|
||||
head_branch: input.head_branch,
|
||||
repo: input.repo,
|
||||
regime: input.regime,
|
||||
ts: input.ts,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConsumeExpectation {
|
||||
pr: number;
|
||||
repo: string;
|
||||
/** Max age in ms before the state is considered stale. Default 6h. */
|
||||
maxAgeMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Validate a last-land.json the parent is about to consume. Guards against
|
||||
* stale-state-drives-wrong-deploy (H5): the file must be for THIS pr+repo,
|
||||
* recent, schema-compatible, and carry a non-null SHA.
|
||||
*/
|
||||
export function validateConsume(
|
||||
state: Partial<LandState> | null,
|
||||
expected: ConsumeExpectation,
|
||||
nowMs: number,
|
||||
): { ok: boolean; reason?: string } {
|
||||
if (!state) return { ok: false, reason: 'no last-land.json found' };
|
||||
if (state.schema_version !== LAND_STATE_SCHEMA_VERSION) {
|
||||
return { ok: false, reason: `schema_version mismatch (got ${state.schema_version}, want ${LAND_STATE_SCHEMA_VERSION})` };
|
||||
}
|
||||
if (!state.sha) return { ok: false, reason: 'last-land.json has no merge SHA' };
|
||||
if (state.pr !== expected.pr) {
|
||||
return { ok: false, reason: `last-land.json is for PR #${state.pr}, not #${expected.pr}` };
|
||||
}
|
||||
if (state.repo !== expected.repo) {
|
||||
return { ok: false, reason: `last-land.json is for repo ${state.repo}, not ${expected.repo}` };
|
||||
}
|
||||
const ts = state.ts ? Date.parse(state.ts) : NaN;
|
||||
if (Number.isNaN(ts)) return { ok: false, reason: 'last-land.json has an invalid timestamp' };
|
||||
const maxAge = expected.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
||||
if (nowMs - ts > maxAge) {
|
||||
return { ok: false, reason: `last-land.json is stale (${Math.round((nowMs - ts) / 60000)} min old)` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "gstack",
|
||||
"version": "1.55.1.0",
|
||||
"version": "1.56.0.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -143,6 +143,11 @@
|
|||
"routing": "Updates StateServer.swift, DebugOverlay.swift, Package.swift,\nand the typed @Observable state accessors. Use after you upgrade gstack\nor add new ViewModels/properties that need accessor coverage.\nUse when asked to \"resync the iOS debug bridge\", \"regenerate iOS\naccessors\", or \"update the gstack iOS instrumentation\".",
|
||||
"voice_line": "Voice triggers (speech-to-text aliases): \"resync the iOS debug bridge\", \"regenerate iOS accessors\", \"update the gstack iOS instrumentation\"."
|
||||
},
|
||||
"land": {
|
||||
"lead": "Land a PR through the right merge regime: pre-flight, CI wait, VERSION-drift check, pre-merge readiness gate, then merge via no-queue,",
|
||||
"routing": "GitHub native merge\nqueue, or trunk.io merge queue. This is the \"land\" half of /land-and-deploy,\nusable on its own when you want to merge but not deploy. Use when: \"land\",\n\"land the pr\", \"land it\", \"merge\", \"merge the pr\", \"merge it\", \"get it merged\".\nFor deploy + canary verification after landing, use /land-and-deploy.",
|
||||
"voice_line": null
|
||||
},
|
||||
"land-and-deploy": {
|
||||
"lead": "Land and deploy workflow.",
|
||||
"routing": "Merges the PR, waits for CI and deploy,\nverifies production health via canary checks. Takes over after /ship\ncreates the PR. Use when: \"merge\", \"land\", \"deploy\", \"merge and verify\",\n\"land it\", \"ship it to production\".",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
}
|
||||
|
|
@ -724,11 +724,14 @@ Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXI
|
|||
|
||||
# /setup-deploy — Configure Deployment for gstack
|
||||
|
||||
You are helping the user configure their deployment so `/land-and-deploy` works
|
||||
automatically. Your job is to detect the deploy platform, production URL, health
|
||||
checks, and deploy status commands — then persist everything to CLAUDE.md.
|
||||
You are helping the user configure their deployment so `/land` and `/land-and-deploy`
|
||||
work automatically. Your job is to detect the deploy platform, production URL, health
|
||||
checks, deploy status commands, AND the merge regime (no queue / GitHub native merge
|
||||
queue / trunk.io merge queue) — then persist everything to CLAUDE.md in two sections:
|
||||
`## Deploy Configuration` and `## Merge Configuration`.
|
||||
|
||||
After this runs once, `/land-and-deploy` reads CLAUDE.md and skips detection entirely.
|
||||
After this runs once, `/land` reads `## Merge Configuration` and `/land-and-deploy` reads
|
||||
both sections, skipping detection entirely.
|
||||
|
||||
## User-invocable
|
||||
When the user types `/setup-deploy`, run this skill.
|
||||
|
|
@ -855,6 +858,30 @@ Use AskUserQuestion to gather the information:
|
|||
- Commands to run before merging (e.g., `bun run build`)
|
||||
- Commands to run after merge but before deploy verification
|
||||
|
||||
### Step 3.5: Detect the merge regime
|
||||
|
||||
How a PR actually merges is separate from how it deploys — a repo can deploy to Fly
|
||||
and still gate merges behind the trunk.io merge queue. Detect the merge regime with the
|
||||
same helper `/land` uses (so the two never disagree):
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-merge detect --json 2>/dev/null
|
||||
```
|
||||
|
||||
This prints `{"regime":"none|github|trunk","source":"..."}`:
|
||||
- **none** — no merge queue; PRs merge directly (`gh pr merge --squash`).
|
||||
- **github** — GitHub's native merge queue (branch protection).
|
||||
- **trunk** — the trunk.io merge queue (detected from the `Trunk Merge Queue (<base>)`
|
||||
status check or a `merge:` section in `.trunk/trunk.yaml`).
|
||||
|
||||
Show the detected regime and confirm it with the user. If the user says it's wrong (e.g.
|
||||
they know trunk.io is set up but the GitHub App isn't installed yet), take their answer.
|
||||
|
||||
If the regime is **trunk** and the user wants the optional REST features (queue position,
|
||||
priority, metrics), tell them: "Trunk's core flow works with zero setup via GitHub
|
||||
comments. For queue position/priority, set `TRUNK_API_TOKEN` in your shell (get it from
|
||||
the Trunk app: Settings > Organization > General > API). It is never written to CLAUDE.md."
|
||||
|
||||
### Step 4: Write configuration
|
||||
|
||||
Read CLAUDE.md (or create it). Find and replace the `## Deploy Configuration` section
|
||||
|
|
@ -866,7 +893,6 @@ if it exists, or append it at the end.
|
|||
- Production URL: {url}
|
||||
- Deploy workflow: {workflow file or "auto-deploy on push"}
|
||||
- Deploy status command: {command or "HTTP health check"}
|
||||
- Merge method: {squash/merge/rebase}
|
||||
- Project type: {web app / API / CLI / library}
|
||||
- Post-deploy health check: {health check URL or command}
|
||||
|
||||
|
|
@ -877,6 +903,109 @@ if it exists, or append it at the end.
|
|||
- Health check: {URL or command}
|
||||
```
|
||||
|
||||
Then, as a **separate top-level section**, find and replace the `## Merge Configuration`
|
||||
section if it exists, or append it. Keep it separate from Deploy Configuration so that
|
||||
`/land` (which only lands, never deploys) reads merge settings with zero deploy coupling:
|
||||
|
||||
```markdown
|
||||
## Merge Configuration (configured by /setup-deploy)
|
||||
- Merge queue: {none / github / trunk}
|
||||
- Merge method: {squash / merge / rebase} (no-queue repos only; queues own their method)
|
||||
- Trunk API token: {"set in $TRUNK_API_TOKEN (optional, never stored here)" / "not used"}
|
||||
```
|
||||
|
||||
`/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:
|
||||
|
|
@ -903,13 +1032,14 @@ Platform: {platform}
|
|||
URL: {url}
|
||||
Health check: {health check}
|
||||
Status cmd: {status command}
|
||||
Merge queue: {none / github / trunk}
|
||||
Merge method: {merge method}
|
||||
|
||||
Saved to CLAUDE.md. /land-and-deploy will use these settings automatically.
|
||||
Saved to CLAUDE.md. /land and /land-and-deploy will use these settings automatically.
|
||||
|
||||
Next steps:
|
||||
- Run /land-and-deploy to merge and deploy your current PR
|
||||
- Edit the "## Deploy Configuration" section in CLAUDE.md to change settings
|
||||
- Run /land to land the current PR (merge only), or /land-and-deploy to merge + deploy + verify
|
||||
- Edit the "## Deploy Configuration" or "## Merge Configuration" section in CLAUDE.md to change settings
|
||||
- Run /setup-deploy again to reconfigure
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,14 @@ allowed-tools:
|
|||
|
||||
# /setup-deploy — Configure Deployment for gstack
|
||||
|
||||
You are helping the user configure their deployment so `/land-and-deploy` works
|
||||
automatically. Your job is to detect the deploy platform, production URL, health
|
||||
checks, and deploy status commands — then persist everything to CLAUDE.md.
|
||||
You are helping the user configure their deployment so `/land` and `/land-and-deploy`
|
||||
work automatically. Your job is to detect the deploy platform, production URL, health
|
||||
checks, deploy status commands, AND the merge regime (no queue / GitHub native merge
|
||||
queue / trunk.io merge queue) — then persist everything to CLAUDE.md in two sections:
|
||||
`## Deploy Configuration` and `## Merge Configuration`.
|
||||
|
||||
After this runs once, `/land-and-deploy` reads CLAUDE.md and skips detection entirely.
|
||||
After this runs once, `/land` reads `## Merge Configuration` and `/land-and-deploy` reads
|
||||
both sections, skipping detection entirely.
|
||||
|
||||
## User-invocable
|
||||
When the user types `/setup-deploy`, run this skill.
|
||||
|
|
@ -158,6 +161,30 @@ Use AskUserQuestion to gather the information:
|
|||
- Commands to run before merging (e.g., `bun run build`)
|
||||
- Commands to run after merge but before deploy verification
|
||||
|
||||
### Step 3.5: Detect the merge regime
|
||||
|
||||
How a PR actually merges is separate from how it deploys — a repo can deploy to Fly
|
||||
and still gate merges behind the trunk.io merge queue. Detect the merge regime with the
|
||||
same helper `/land` uses (so the two never disagree):
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-merge detect --json 2>/dev/null
|
||||
```
|
||||
|
||||
This prints `{"regime":"none|github|trunk","source":"..."}`:
|
||||
- **none** — no merge queue; PRs merge directly (`gh pr merge --squash`).
|
||||
- **github** — GitHub's native merge queue (branch protection).
|
||||
- **trunk** — the trunk.io merge queue (detected from the `Trunk Merge Queue (<base>)`
|
||||
status check or a `merge:` section in `.trunk/trunk.yaml`).
|
||||
|
||||
Show the detected regime and confirm it with the user. If the user says it's wrong (e.g.
|
||||
they know trunk.io is set up but the GitHub App isn't installed yet), take their answer.
|
||||
|
||||
If the regime is **trunk** and the user wants the optional REST features (queue position,
|
||||
priority, metrics), tell them: "Trunk's core flow works with zero setup via GitHub
|
||||
comments. For queue position/priority, set `TRUNK_API_TOKEN` in your shell (get it from
|
||||
the Trunk app: Settings > Organization > General > API). It is never written to CLAUDE.md."
|
||||
|
||||
### Step 4: Write configuration
|
||||
|
||||
Read CLAUDE.md (or create it). Find and replace the `## Deploy Configuration` section
|
||||
|
|
@ -169,7 +196,6 @@ if it exists, or append it at the end.
|
|||
- Production URL: {url}
|
||||
- Deploy workflow: {workflow file or "auto-deploy on push"}
|
||||
- Deploy status command: {command or "HTTP health check"}
|
||||
- Merge method: {squash/merge/rebase}
|
||||
- Project type: {web app / API / CLI / library}
|
||||
- Post-deploy health check: {health check URL or command}
|
||||
|
||||
|
|
@ -180,6 +206,27 @@ if it exists, or append it at the end.
|
|||
- Health check: {URL or command}
|
||||
```
|
||||
|
||||
Then, as a **separate top-level section**, find and replace the `## Merge Configuration`
|
||||
section if it exists, or append it. Keep it separate from Deploy Configuration so that
|
||||
`/land` (which only lands, never deploys) reads merge settings with zero deploy coupling:
|
||||
|
||||
```markdown
|
||||
## Merge Configuration (configured by /setup-deploy)
|
||||
- Merge queue: {none / github / trunk}
|
||||
- Merge method: {squash / merge / rebase} (no-queue repos only; queues own their method)
|
||||
- Trunk API token: {"set in $TRUNK_API_TOKEN (optional, never stored here)" / "not used"}
|
||||
```
|
||||
|
||||
`/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:
|
||||
|
|
@ -206,13 +253,14 @@ Platform: {platform}
|
|||
URL: {url}
|
||||
Health check: {health check}
|
||||
Status cmd: {status command}
|
||||
Merge queue: {none / github / trunk}
|
||||
Merge method: {merge method}
|
||||
|
||||
Saved to CLAUDE.md. /land-and-deploy will use these settings automatically.
|
||||
Saved to CLAUDE.md. /land and /land-and-deploy will use these settings automatically.
|
||||
|
||||
Next steps:
|
||||
- Run /land-and-deploy to merge and deploy your current PR
|
||||
- Edit the "## Deploy Configuration" section in CLAUDE.md to change settings
|
||||
- Run /land to land the current PR (merge only), or /land-and-deploy to merge + deploy + verify
|
||||
- Edit the "## Deploy Configuration" or "## Merge Configuration" section in CLAUDE.md to change settings
|
||||
- Run /setup-deploy again to reconfigure
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1436,6 +1436,121 @@ describe('INVOKE_SKILL resolver', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// --- /land skill: composition into /land-and-deploy + generated-doc scrub (H9) ---
|
||||
|
||||
describe('/land skill composition', () => {
|
||||
const landTmpl = fs.readFileSync(path.join(ROOT, 'land', 'SKILL.md.tmpl'), 'utf-8');
|
||||
const landMd = fs.readFileSync(path.join(ROOT, 'land', 'SKILL.md'), 'utf-8');
|
||||
const ladTmpl = fs.readFileSync(path.join(ROOT, 'land-and-deploy', 'SKILL.md.tmpl'), 'utf-8');
|
||||
const ladMd = fs.readFileSync(path.join(ROOT, 'land-and-deploy', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('land-and-deploy composes /land via {{INVOKE_SKILL:land}}', () => {
|
||||
expect(ladTmpl).toContain('{{INVOKE_SKILL:land}}');
|
||||
});
|
||||
|
||||
test('land-and-deploy SKILL.md resolves the composition prose to the land skill', () => {
|
||||
expect(ladMd).toContain('land/SKILL.md');
|
||||
expect(ladMd).toContain('Follow its instructions from top to bottom');
|
||||
});
|
||||
|
||||
test('land-and-deploy no longer carries its own merge step (merge lives in /land)', () => {
|
||||
// The parent must compose /land, not run gh pr merge itself.
|
||||
expect(ladMd).not.toContain('gh pr merge --auto --delete-branch');
|
||||
expect(ladMd).not.toContain('## Step 4: Merge the PR');
|
||||
});
|
||||
|
||||
test('land-and-deploy consumes the handoff via gstack-merge read-state', () => {
|
||||
expect(ladMd).toContain('gstack-merge read-state');
|
||||
expect(ladMd).toContain('last-land.json');
|
||||
});
|
||||
|
||||
// H9 — generated-doc scrub: extracting the land half "verbatim" risks dragging
|
||||
// deploy/canary machinery into /land. Forbid the machinery tokens (not the words
|
||||
// "deploy"/"canary", which legitimately appear in /land-and-deploy cross-refs).
|
||||
test('/land SKILL.md carries no deploy/canary machinery (H9)', () => {
|
||||
const forbidden = [
|
||||
'deploy-reports',
|
||||
'DEPLOY_BOOTSTRAP',
|
||||
'$B goto',
|
||||
'$B console',
|
||||
'$B perf',
|
||||
'$B snapshot',
|
||||
'Canary verification',
|
||||
'Wait for deploy',
|
||||
'gh run list',
|
||||
];
|
||||
const offenders = forbidden.filter((s) => landMd.includes(s));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('/land drives the merge through the gstack-merge helper', () => {
|
||||
expect(landMd).toContain('gstack-merge submit');
|
||||
expect(landMd).toContain('gstack-merge wait');
|
||||
expect(landMd).toContain('gstack-merge write-state');
|
||||
expect(landMd).toContain('gstack-merge detect');
|
||||
});
|
||||
|
||||
test('/land uses {{BASE_BRANCH_DETECT}} so composition correctly skips the duplicate', () => {
|
||||
// The INVOKE_SKILL skip-list skips "Step 0: Detect platform and base branch",
|
||||
// which is exactly what BASE_BRANCH_DETECT emits — so the parent's detection
|
||||
// wins when composed, and standalone /land runs its own.
|
||||
expect(landTmpl).toContain('{{BASE_BRANCH_DETECT}}');
|
||||
});
|
||||
|
||||
test('/land documents all three merge regimes', () => {
|
||||
expect(landMd).toContain('trunk');
|
||||
expect(landMd).toContain('/trunk merge');
|
||||
expect(landMd).toMatch(/gh pr merge .*--squash/);
|
||||
expect(landMd).toContain('--auto');
|
||||
});
|
||||
});
|
||||
|
||||
// --- /land enqueue-and-return + onboarding (D4/D5/D6) ---
|
||||
|
||||
describe('/land enqueue-and-return + merge-queue onboarding', () => {
|
||||
const landMd = fs.readFileSync(path.join(ROOT, 'land', 'SKILL.md'), 'utf-8');
|
||||
const setupMd = fs.readFileSync(path.join(ROOT, 'setup-deploy', 'SKILL.md'), 'utf-8');
|
||||
const ladMd = fs.readFileSync(path.join(ROOT, 'land-and-deploy', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('D4: enqueue-and-return is the default for queue regimes, with a --watch opt-in', () => {
|
||||
expect(landMd).toContain('confirm-enqueue');
|
||||
expect(landMd).toContain('enqueue-and-return');
|
||||
expect(landMd).toContain('--watch');
|
||||
// The default must NOT block on the queue; --watch is the blocking path.
|
||||
expect(landMd).toMatch(/Default for a merge queue is enqueue-and-return/i);
|
||||
});
|
||||
|
||||
test('D4: land-and-deploy forces /land into --watch (it needs the completed merge)', () => {
|
||||
expect(ladMd).toContain('--watch');
|
||||
expect(ladMd).toMatch(/as if invoked with .*--watch|--watch branch/);
|
||||
});
|
||||
|
||||
test('D5: the skill explains what a merge queue is and what it will do', () => {
|
||||
expect(landMd).toMatch(/what a merge queue is|how this lands/i);
|
||||
expect(landMd).toMatch(/walk away/i);
|
||||
expect(landMd).toContain('optimistic');
|
||||
});
|
||||
|
||||
test('D6: the shared {{MERGE_QUEUE_SETUP}} onboarding resolves in BOTH /land and /setup-deploy', () => {
|
||||
// No literal placeholder left anywhere.
|
||||
expect(landMd).not.toContain('{{MERGE_QUEUE_SETUP}}');
|
||||
expect(setupMd).not.toContain('{{MERGE_QUEUE_SETUP}}');
|
||||
// The authoritative onboarding text appears in both (single source, two includes).
|
||||
expect(landMd).toContain('Set up a merge queue with trunk.io');
|
||||
expect(setupMd).toContain('Set up a merge queue with trunk.io');
|
||||
// It hand-holds the load-bearing trunk steps.
|
||||
expect(landMd).toContain('Trunk GitHub App');
|
||||
expect(landMd).toContain('app.trunk.io');
|
||||
expect(landMd).toMatch(/trunk-merge\/\*/);
|
||||
});
|
||||
|
||||
test('bin/gstack-merge exposes the confirm-enqueue subcommand', () => {
|
||||
const bin = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-merge'), 'utf-8');
|
||||
expect(bin).toContain("case 'confirm-enqueue':");
|
||||
expect(bin).toContain('last-enqueue.json');
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{CHANGELOG_WORKFLOW}} resolver tests ---
|
||||
|
||||
describe('CHANGELOG_WORKFLOW resolver', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
// Deterministic end-to-end coverage of the bin/gstack-merge CLI (arg parsing,
|
||||
// file IO, and the lib/merge.ts wiring through the real binary). Free + fast —
|
||||
// no claude -p, no network. The submit/wait/classify *logic* is unit-tested in
|
||||
// gstack-merge.test.ts; this proves the executable plumbs it correctly and that
|
||||
// the handoff contract (write → consume, with null/stale/foreign rejection)
|
||||
// round-trips through the actual CLI surface /land and /land-and-deploy call.
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const BIN = join(import.meta.dir, '..', 'bin', 'gstack-merge');
|
||||
|
||||
function runCli(args: string[], opts: { cwd?: string; home?: string } = {}) {
|
||||
const r = spawnSync('bun', [BIN, ...args], {
|
||||
encoding: 'utf-8',
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: { ...process.env, ...(opts.home ? { HOME: opts.home } : {}) },
|
||||
timeout: 30000,
|
||||
});
|
||||
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'gstack-merge-cli-')); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
describe('gstack-merge CLI: detect', () => {
|
||||
test('an explicit "Merge queue: trunk" config wins (no git/gh needed)', () => {
|
||||
// A non-git temp dir: gh/git calls return nothing, so the config key is the
|
||||
// only signal — exactly the precedence /land relies on.
|
||||
writeFileSync(join(tmp, 'CLAUDE.md'), '# proj\n\n## Merge Configuration\n- Merge queue: trunk\n');
|
||||
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
|
||||
expect(r.code).toBe(0);
|
||||
const out = JSON.parse(r.stdout.trim());
|
||||
expect(out.regime).toBe('trunk');
|
||||
expect(out.source).toBe('config');
|
||||
});
|
||||
|
||||
test('config "Merge queue: github" is honored', () => {
|
||||
writeFileSync(join(tmp, 'CLAUDE.md'), '## Merge Configuration\n- Merge queue: github\n');
|
||||
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
|
||||
expect(JSON.parse(r.stdout.trim()).regime).toBe('github');
|
||||
});
|
||||
|
||||
test('no config + no signals → none', () => {
|
||||
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout.trim()).regime).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-merge CLI: handoff round-trip (write → consume)', () => {
|
||||
const SLUG = 'acme-widget';
|
||||
const REPO = 'acme/widget';
|
||||
|
||||
function seedHandoff(home: string, overrides: Record<string, unknown> = {}) {
|
||||
const dir = join(home, '.gstack', 'projects', SLUG);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const state = {
|
||||
schema_version: 1,
|
||||
pr: 42,
|
||||
sha: 'deadbeefcafe',
|
||||
headRefOid: 'cafef00d',
|
||||
base: 'main',
|
||||
head_branch: 'feat/x',
|
||||
repo: REPO,
|
||||
regime: 'trunk',
|
||||
ts: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
writeFileSync(join(dir, 'last-land.json'), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
test('read-state accepts a matching recent handoff and emits the SHA', () => {
|
||||
seedHandoff(tmp);
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('LAND_SHA=deadbeefcafe');
|
||||
expect(r.stdout).toContain('LAND_REGIME=trunk');
|
||||
});
|
||||
|
||||
test('read-state rejects a handoff for a different PR (no cross-PR deploy)', () => {
|
||||
seedHandoff(tmp);
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '99', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state rejects a handoff for a different repo', () => {
|
||||
seedHandoff(tmp);
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', 'other/repo'], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state rejects a stale handoff', () => {
|
||||
seedHandoff(tmp, { ts: '2020-01-01T00:00:00.000Z' });
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state rejects a handoff with an empty SHA (the null-SHA STOP)', () => {
|
||||
seedHandoff(tmp, { sha: '' });
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state reports missing when there is no handoff at all', () => {
|
||||
const r = runCli(['read-state', '--slug', 'nope', '--pr', '1', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-merge CLI: argument handling', () => {
|
||||
test('unknown subcommand exits 2 with usage', () => {
|
||||
const r = runCli(['frobnicate']);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('usage: gstack-merge');
|
||||
});
|
||||
|
||||
test('submit without --pr exits 2', () => {
|
||||
const r = runCli(['submit', '--regime', 'none']);
|
||||
expect(r.code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
detectRegime,
|
||||
planSubmit,
|
||||
classifyLand,
|
||||
buildLandState,
|
||||
validateConsume,
|
||||
isTrunkQueueCheck,
|
||||
trunkQueueCheckName,
|
||||
LAND_STATE_SCHEMA_VERSION,
|
||||
type LandState,
|
||||
} from '../lib/merge';
|
||||
|
||||
describe('detectRegime', () => {
|
||||
test('explicit config key wins over everything', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
configRegime: 'github',
|
||||
checks: [{ name: trunkQueueCheckName('main'), state: 'PENDING' }], // would say trunk
|
||||
trunkYaml: 'merge:\n required_statuses: [ci]',
|
||||
githubMergeQueue: false,
|
||||
});
|
||||
expect(r.regime).toBe('github');
|
||||
expect(r.source).toBe('config');
|
||||
});
|
||||
|
||||
test('invalid config key is ignored and falls through to live signals', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
configRegime: 'banana',
|
||||
checks: [{ name: 'Trunk Merge Queue (main)' }],
|
||||
trunkYaml: null,
|
||||
});
|
||||
expect(r.regime).toBe('trunk');
|
||||
expect(r.source).toBe('trunk-status-check');
|
||||
});
|
||||
|
||||
test('trunk status check on the PR → trunk (even on a non-main base)', () => {
|
||||
const r = detectRegime({
|
||||
base: 'develop',
|
||||
checks: [{ name: 'Trunk Merge Queue (develop)', state: 'TESTING' }],
|
||||
trunkYaml: null,
|
||||
});
|
||||
expect(r.regime).toBe('trunk');
|
||||
expect(r.source).toBe('trunk-status-check');
|
||||
});
|
||||
|
||||
test('.trunk/trunk.yaml with a merge: section → trunk (secondary signal)', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
checks: [{ name: 'build' }, { name: 'test' }],
|
||||
trunkYaml: 'version: 0.1\nmerge:\n required_statuses:\n - build\n',
|
||||
});
|
||||
expect(r.regime).toBe('trunk');
|
||||
expect(r.source).toBe('trunk-yaml');
|
||||
});
|
||||
|
||||
test('bare .trunk/trunk.yaml WITHOUT a merge: section is NOT trunk (check-only false positive guard)', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
checks: [{ name: 'build' }],
|
||||
trunkYaml: 'version: 0.1\ncli:\n version: 1.22.0\nlint:\n enabled:\n - eslint\n',
|
||||
});
|
||||
expect(r.regime).toBe('none');
|
||||
expect(r.source).toBe('default');
|
||||
});
|
||||
|
||||
test('github branch-protection merge queue → github', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
checks: [{ name: 'build' }],
|
||||
trunkYaml: null,
|
||||
githubMergeQueue: true,
|
||||
});
|
||||
expect(r.regime).toBe('github');
|
||||
expect(r.source).toBe('github-branch-protection');
|
||||
});
|
||||
|
||||
test('no signals → none', () => {
|
||||
const r = detectRegime({ base: 'main', checks: [], trunkYaml: null });
|
||||
expect(r.regime).toBe('none');
|
||||
expect(r.source).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTrunkQueueCheck', () => {
|
||||
test('matches the queue check name for any branch', () => {
|
||||
expect(isTrunkQueueCheck('Trunk Merge Queue (main)')).toBe(true);
|
||||
expect(isTrunkQueueCheck('Trunk Merge Queue (release/v2)')).toBe(true);
|
||||
});
|
||||
test('does not match unrelated checks', () => {
|
||||
expect(isTrunkQueueCheck('Trunk Check')).toBe(false);
|
||||
expect(isTrunkQueueCheck('build')).toBe(false);
|
||||
expect(isTrunkQueueCheck(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planSubmit', () => {
|
||||
test('none → single direct squash with branch delete', () => {
|
||||
const p = planSubmit('none', 42);
|
||||
expect(p.deleteBranch).toBe(true);
|
||||
expect(p.candidates).toHaveLength(1);
|
||||
expect(p.candidates[0].args).toEqual(['pr', 'merge', '42', '--squash', '--delete-branch']);
|
||||
});
|
||||
|
||||
test('github → auto-merge first, squash fallback', () => {
|
||||
const p = planSubmit('github', 42);
|
||||
expect(p.deleteBranch).toBe(true);
|
||||
expect(p.candidates[0].args).toContain('--auto');
|
||||
expect(p.candidates[1].args).toContain('--squash');
|
||||
});
|
||||
|
||||
test('trunk is comment-first and never deletes the branch', () => {
|
||||
const p = planSubmit('trunk', 7, { trunkCliAvailable: false, trunkToken: false });
|
||||
expect(p.deleteBranch).toBe(false);
|
||||
expect(p.candidates).toHaveLength(1);
|
||||
expect(p.candidates[0].cmd).toBe('gh');
|
||||
expect(p.candidates[0].args).toEqual(['pr', 'comment', '7', '--body', '/trunk merge']);
|
||||
// No gh pr merge anywhere in the trunk plan.
|
||||
expect(p.candidates.some((c) => c.args.includes('merge') && c.cmd === 'gh' && c.args.includes('pr') && c.args[1] === 'merge')).toBe(false);
|
||||
});
|
||||
|
||||
test('trunk adds CLI then REST as opportunistic fallbacks, in order', () => {
|
||||
const p = planSubmit('trunk', 7, { trunkCliAvailable: true, trunkToken: true });
|
||||
expect(p.candidates.map((c) => c.cmd)).toEqual(['gh', 'trunk', 'trunk-rest']);
|
||||
});
|
||||
|
||||
test('trunk priority threads into the comment body and CLI', () => {
|
||||
const p = planSubmit('trunk', 7, { trunkCliAvailable: true, priority: 'high' });
|
||||
expect(p.candidates[0].args).toContain('/trunk merge --priority=high');
|
||||
expect(p.candidates[1].args).toEqual(['merge', '7', '--priority', 'high']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyLand', () => {
|
||||
test('MERGED with a merge SHA → landed', () => {
|
||||
const c = classifyLand({ state: 'MERGED', mergeCommitOid: 'abc123', baseContainsHead: false });
|
||||
expect(c.status).toBe('landed');
|
||||
});
|
||||
|
||||
test('MERGED with null SHA but base contains head → landed (rebase-merge case, H3)', () => {
|
||||
const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: true });
|
||||
expect(c.status).toBe('landed');
|
||||
});
|
||||
|
||||
test('MERGED but SHA not visible and base does not yet contain head → pending (squash lag)', () => {
|
||||
const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: false });
|
||||
expect(c.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('OPEN with a failed queue check → ejected', () => {
|
||||
const c = classifyLand({
|
||||
state: 'OPEN',
|
||||
mergeCommitOid: null,
|
||||
baseContainsHead: false,
|
||||
queueCheck: { name: 'Trunk Merge Queue (main)', state: 'FAILURE' },
|
||||
});
|
||||
expect(c.status).toBe('ejected');
|
||||
});
|
||||
|
||||
test('OPEN with a cancelled queue check → ejected', () => {
|
||||
const c = classifyLand({
|
||||
state: 'OPEN',
|
||||
mergeCommitOid: null,
|
||||
baseContainsHead: false,
|
||||
queueCheck: { name: 'Trunk Merge Queue (main)', bucket: 'CANCELLED' },
|
||||
});
|
||||
expect(c.status).toBe('ejected');
|
||||
});
|
||||
|
||||
test('OPEN with a still-testing queue check → pending', () => {
|
||||
const c = classifyLand({
|
||||
state: 'OPEN',
|
||||
mergeCommitOid: null,
|
||||
baseContainsHead: false,
|
||||
queueCheck: { name: 'Trunk Merge Queue (main)', state: 'IN_PROGRESS' },
|
||||
});
|
||||
expect(c.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('CLOSED → closed', () => {
|
||||
const c = classifyLand({ state: 'CLOSED', mergeCommitOid: null, baseContainsHead: false });
|
||||
expect(c.status).toBe('closed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLandState', () => {
|
||||
const base = {
|
||||
pr: 12,
|
||||
sha: 'deadbeef',
|
||||
headRefOid: 'cafe',
|
||||
base: 'main',
|
||||
head_branch: 'feat/x',
|
||||
repo: 'owner/name',
|
||||
regime: 'trunk' as const,
|
||||
ts: '2026-05-31T00:00:00.000Z',
|
||||
};
|
||||
|
||||
test('assembles a schema-versioned state', () => {
|
||||
const s = buildLandState(base);
|
||||
expect(s.schema_version).toBe(LAND_STATE_SCHEMA_VERSION);
|
||||
expect(s.sha).toBe('deadbeef');
|
||||
expect(s.repo).toBe('owner/name');
|
||||
// scope is intentionally absent (T2 — parent recomputes diff-scope).
|
||||
expect('scope' in s).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses to build with an empty SHA (handoff would silently kill revert)', () => {
|
||||
expect(() => buildLandState({ ...base, sha: '' })).toThrow(/empty merge SHA/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConsume', () => {
|
||||
const now = Date.parse('2026-05-31T01:00:00.000Z');
|
||||
const good: LandState = {
|
||||
schema_version: LAND_STATE_SCHEMA_VERSION,
|
||||
pr: 12,
|
||||
sha: 'deadbeef',
|
||||
headRefOid: 'cafe',
|
||||
base: 'main',
|
||||
head_branch: 'feat/x',
|
||||
repo: 'owner/name',
|
||||
regime: 'trunk',
|
||||
ts: '2026-05-31T00:30:00.000Z',
|
||||
};
|
||||
|
||||
test('accepts a matching recent state', () => {
|
||||
expect(validateConsume(good, { pr: 12, repo: 'owner/name' }, now).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects when no state file', () => {
|
||||
const v = validateConsume(null, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a state for a different PR (stale-state-drives-wrong-deploy, H5)', () => {
|
||||
const v = validateConsume(good, { pr: 99, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reason).toMatch(/PR #12/);
|
||||
});
|
||||
|
||||
test('rejects a state for a different repo', () => {
|
||||
const v = validateConsume(good, { pr: 12, repo: 'other/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a stale state past max age', () => {
|
||||
const stale = { ...good, ts: '2026-05-30T00:00:00.000Z' };
|
||||
const v = validateConsume(stale, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reason).toMatch(/stale/);
|
||||
});
|
||||
|
||||
test('rejects an empty SHA', () => {
|
||||
const v = validateConsume({ ...good, sha: '' }, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a schema_version mismatch', () => {
|
||||
const v = validateConsume({ ...good, schema_version: 99 }, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -208,7 +208,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
|||
// Ship
|
||||
'ship-base-branch': ['ship/**', 'bin/gstack-repo-mode'],
|
||||
'ship-local-workflow': ['ship/**', 'scripts/gen-skill-docs.ts'],
|
||||
'review-dashboard-via': ['ship/**', 'scripts/resolvers/review.ts', 'codex/**', 'autoplan/**', 'land-and-deploy/**'],
|
||||
'review-dashboard-via': ['ship/**', 'scripts/resolvers/review.ts', 'codex/**', 'autoplan/**', 'land-and-deploy/**', 'land/**'],
|
||||
'ship-plan-completion': ['ship/**', 'scripts/gen-skill-docs.ts'],
|
||||
'ship-plan-verification': ['ship/**', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
|
|
@ -288,13 +288,16 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
|||
// gstack-upgrade
|
||||
'gstack-upgrade-happy-path': ['gstack-upgrade/**'],
|
||||
|
||||
// Deploy skills
|
||||
'land-and-deploy-workflow': ['land-and-deploy/**', 'scripts/gen-skill-docs.ts'],
|
||||
'land-and-deploy-first-run': ['land-and-deploy/**', 'scripts/gen-skill-docs.ts', 'bin/gstack-slug'],
|
||||
'land-and-deploy-review-gate': ['land-and-deploy/**', 'bin/gstack-review-read'],
|
||||
// Deploy skills. land-and-deploy now composes /land and drives merges through
|
||||
// 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/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/**', '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/**'],
|
||||
|
|
|
|||
|
|
@ -2,31 +2,30 @@
|
|||
* Coverage for PR #1620 — Post-failure PR-state check after `gh pr merge`
|
||||
* non-zero exit.
|
||||
*
|
||||
* The fix lives in land-and-deploy/SKILL.md.tmpl as Step §4a-postfail.
|
||||
* After ANY non-zero `gh pr merge`, the skill must query authoritative PR
|
||||
* state via `gh pr view --json state,mergeCommit,mergedAt,mergedBy` and
|
||||
* branch on the result instead of retrying `gh pr merge` (cli/cli#3442,
|
||||
* cli/cli#13380).
|
||||
* The merge step (and this invariant) moved out of /land-and-deploy into the
|
||||
* extracted /land skill (§4.2a). After ANY non-zero `gh pr merge`, the skill
|
||||
* must query authoritative PR state via
|
||||
* `gh pr view --json state,mergeCommit,mergedAt,mergedBy` and branch on the
|
||||
* result instead of retrying `gh pr merge` (cli/cli#3442, cli/cli#13380).
|
||||
*
|
||||
* Static invariants pin:
|
||||
* - §4a-postfail header present
|
||||
* Static invariants pin (now in /land):
|
||||
* - §4.2a Post-failure PR-state check header present
|
||||
* - Universal invariant text + reference to upstream gh bugs
|
||||
* - All three state branches (MERGED, OPEN, CLOSED) named explicitly
|
||||
* - MERGED branch: capture merge SHA via mergeCommit.oid
|
||||
* - MERGED branch: non-destructive worktree cleanup with uncommitted-work guard
|
||||
* - MERGED branch: continues to §4a CI watch
|
||||
* - OPEN branch: checks autoMergeRequest before treating as failure
|
||||
* - CLOSED branch: STOPs
|
||||
* - Hard rule: never retry `gh pr merge`
|
||||
* - .tmpl edit propagated to generated SKILL.md (atomic per T-Codex-3)
|
||||
* - .tmpl edit propagated to generated SKILL.md (atomic regen)
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const TMPL = path.join(ROOT, "land-and-deploy", "SKILL.md.tmpl");
|
||||
const MD = path.join(ROOT, "land-and-deploy", "SKILL.md");
|
||||
const TMPL = path.join(ROOT, "land", "SKILL.md.tmpl");
|
||||
const MD = path.join(ROOT, "land", "SKILL.md");
|
||||
|
||||
function readTmpl(): string {
|
||||
return fs.readFileSync(TMPL, "utf-8");
|
||||
|
|
@ -35,18 +34,20 @@ function readMd(): string {
|
|||
return fs.readFileSync(MD, "utf-8");
|
||||
}
|
||||
|
||||
describe("PR #1620 §4a-postfail in land-and-deploy template", () => {
|
||||
test("§4a-postfail header present in template", () => {
|
||||
expect(readTmpl()).toMatch(/### 4a-postfail: Post-failure PR-state check/);
|
||||
describe("PR #1620 post-failure PR-state check in /land template", () => {
|
||||
test("post-failure header present in template", () => {
|
||||
expect(readTmpl()).toMatch(/### 4\.2a: Post-failure PR-state check/);
|
||||
});
|
||||
|
||||
test("§4a-postfail comes before §4a (Merge queue detection)", () => {
|
||||
test("post-failure check comes before the landing step (4.3)", () => {
|
||||
const body = readTmpl();
|
||||
const postfail = body.indexOf("### 4a-postfail:");
|
||||
const queue = body.indexOf("### 4a: Merge queue detection");
|
||||
const postfail = body.indexOf("### 4.2a: Post-failure PR-state check");
|
||||
// 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(queue).toBeGreaterThan(-1);
|
||||
expect(postfail).toBeLessThan(queue);
|
||||
expect(landing).toBeGreaterThan(-1);
|
||||
expect(postfail).toBeLessThan(landing);
|
||||
});
|
||||
|
||||
test("Universal invariant + upstream gh bug references", () => {
|
||||
|
|
@ -82,11 +83,6 @@ describe("PR #1620 §4a-postfail in land-and-deploy template", () => {
|
|||
expect(body).toMatch(/Do NOT remove the user's primary working tree/);
|
||||
});
|
||||
|
||||
test("MERGED branch continues to §4a CI auto-deploy detection", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/continue to §4a/);
|
||||
});
|
||||
|
||||
test("OPEN branch checks autoMergeRequest before treating as failure", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/gh pr view --json autoMergeRequest/);
|
||||
|
|
@ -103,9 +99,9 @@ describe("PR #1620 §4a-postfail in land-and-deploy template", () => {
|
|||
expect(body).toMatch(/never call `gh pr merge` a second time/);
|
||||
});
|
||||
|
||||
test("Generated SKILL.md carries the §4a-postfail section (atomic regen per T-Codex-3)", () => {
|
||||
test("Generated SKILL.md carries the post-failure section (atomic regen)", () => {
|
||||
const md = readMd();
|
||||
expect(md).toMatch(/### 4a-postfail: Post-failure PR-state check/);
|
||||
expect(md).toMatch(/### 4\.2a: Post-failure PR-state check/);
|
||||
expect(md).toMatch(/state == "MERGED"/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export const SKILL_COVERAGE: Record<string, SkillCoverage> = {
|
|||
'document-generate': { gate: ['test/skill-coverage-floor.test.ts'], periodic: [] },
|
||||
|
||||
// ─── Ops + integrations ─────────────────────────────────────
|
||||
land: { gate: ['test/gstack-merge.test.ts', 'test/gstack-merge-cli.test.ts', 'test/land-and-deploy-postfail.test.ts', 'test/gen-skill-docs.test.ts', 'test/skill-coverage-floor.test.ts'], periodic: [] },
|
||||
'land-and-deploy': { gate: ['test/skill-e2e-deploy.test.ts', 'test/skill-coverage-floor.test.ts'], periodic: [] },
|
||||
canary: { gate: ['test/skill-coverage-floor.test.ts'], periodic: [] },
|
||||
benchmark: { gate: ['test/skill-e2e-benchmark-providers.test.ts', 'test/skill-coverage-floor.test.ts'], periodic: [] },
|
||||
|
|
|
|||
Loading…
Reference in New Issue