@1aboveio/cicd-skills 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: github-flow
3
3
  description: "Deliver a change after the commit: push, open a PR, wait for checks, and merge to main through the repo's flow (direct push, pull request, or merge queue). Use whenever you commit or finish a change, when CONTRIBUTING.md has a Delivery section, and for asks like 'commit this', 'push it', 'open a PR', 'ship it', 'land it', 'get it merged'. Also: declare a repo's delivery flow, or write the CI conventions and Delivery section into CONTRIBUTING.md on request. NOT for writing workflow YAML (github-actions), Mergify config or a stuck queued PR (mergify), cutting a release (resolve-release), or PRs that resolve-issues or rush-issues deliver."
4
- release-version: 0.50.1
4
+ release-version: 0.50.2
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # GitHub Flow
@@ -58,15 +58,37 @@ Commit only the files of this change. Leave unrelated working-tree changes alone
58
58
  - Stack repo (`mergify-hooks/` in the git hooks dir; the pre-push hook says "Use 'mergify stack
59
59
  push'"): replace steps 2–3 with the `mergify` skill's `scripts/stack-push.mjs`. Put
60
60
  `Closes #<n>` in the commit message. Never `--no-verify`.
61
- 4. Wait for the checks: `gh pr checks --watch --required --fail-fast`.
62
- - "no required checks": run it again without `--required`.
63
- - "no checks reported": wait 30 seconds and retry; after 5 minutes with none, the repo has no CI
64
- for this PR. Continue.
65
- - Red: go to [Fix a red check](#fix-a-red-check).
61
+ 4. Wait for the checks to settle:
62
+
63
+ ```bash
64
+ node "$SKILL_DIR/scripts/watch-checks.mjs" --pr <number>
65
+ ```
66
+
67
+ It preserves the existing bar: required checks, falling back to all checks when none
68
+ are required. It writes one record only when the selected check set actually changes — a stationary phase stays
69
+ quiet rather than redrawing. Read the terminal verdict:
70
+ - exit 0 `CHECKS_PASSED`: go to step 5.
71
+ - exit 0 `NO_CHECKS`: none reported within the bound. Continue only if the repository's
72
+ policy permits no CI; otherwise diagnose the missing checks.
73
+ - exit 4 `CHECKS_FAILED`: the verdict names them. Go to [Fix a red check](#fix-a-red-check).
74
+ - exit 4 `STALLED` / `UNKNOWN`: report the verdict and stop. Never merge on a guess.
75
+
76
+ Avoid redraw-based waits: the elapsed clock in `gh pr checks --watch` looks like
77
+ progress to a log monitor even while one check is stationary. Keep the repository's
78
+ existing check policy; observation does not add optional checks as merge gates.
66
79
  5. Merge once green: `gh pr merge --<mergeMethod> --delete-branch`, adding `--auto` when
67
80
  `autoMerge: true`. On a refusal for review, go to [Stop at a human gate](#stop-at-a-human-gate).
68
- 6. Confirm `gh pr view --json state,mergeCommit` shows `MERGED`, then
69
- `git switch <branch> && git pull --ff-only`.
81
+ 6. Prove the merge — a zero exit from `gh pr merge --auto` means the *request* was accepted:
82
+
83
+ ```bash
84
+ node "$SKILL_DIR/scripts/watch-checks.mjs" --pr <number> --until merged
85
+ ```
86
+
87
+ Continue only after `MERGED`, which prints `pr.mergedSha`; a checkless open PR still
88
+ needs provider merge confirmation. Exit 4 `STALLED` with `reason: merge-gate:blocked` is the
89
+ human gate; `merge-gate:behind` or `merge-gate:dirty` needs a rebase or a conflict fix. A PR
90
+ with an accepted auto-merge request is never called a gate — the provider owns the next step,
91
+ so the ceiling is the hand-back there. Then `git switch <branch> && git pull --ff-only`.
70
92
 
71
93
  ## Flow: merge-queue
72
94
 
@@ -76,7 +98,11 @@ Commit only the files of this change. Leave unrelated working-tree changes alone
76
98
  `scripts/watch-pr-delivery.mjs` to a verdict. Enqueue several ready PRs together.
77
99
  - `queue: github`: `gh pr merge --auto` with no merge method; the queue sets it.
78
100
  3. Dequeued: with Mergify, follow its dequeue-reason table. Re-enqueue only after the cause is fixed.
79
- 4. Confirm the merge and update the local default branch as in pull-request step 6.
101
+ 4. Confirm the merge, then update the local default branch as in pull-request step 6.
102
+ - `queue: mergify`: the queue watcher's `MERGED` verdict carries the merge commit. The
103
+ `queued` label disappearing is not a merge.
104
+ - `queue: github`: `watch-checks.mjs --pr <number> --until merged --max-minutes 120`. The
105
+ ceiling is the hand-back; auto-merge being accepted is not a merge.
80
106
 
81
107
  ## Fix a red check
82
108
 
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env node
2
+ // watch-checks — wait for a PR's checks to settle, and prove the merge when asked.
3
+ // Read-only: it only reads `gh pr view`.
4
+ //
5
+ // node watch-checks.mjs --pr <number> [--repo <owner>/<name>] [--until checks|merged]
6
+ // [--interval-seconds 45] [--stall-minutes 15] [--max-minutes 60]
7
+ // [--no-checks-minutes 5] [--slow-check-minutes 5] [--read-retries 2]
8
+ // [--unreadable-polls 3]
9
+ //
10
+ // Why this exists instead of `gh pr checks --watch --required --fail-fast`:
11
+ //
12
+ // The delivery bar stays unchanged: required checks, or all checks when none are
13
+ // required. Monitoring must not turn optional checks into new merge gates.
14
+ // · `--watch` repaints its table — and its elapsed clock — every 30 seconds. Any
15
+ // supervisor that decides "the command printed something, so it is progressing"
16
+ // from output activity is fooled by that repaint: a job stuck in one step for
17
+ // twenty minutes looks exactly like a job making progress, and the moment of
18
+ // decision is spent on a status line rather than on the step that stopped.
19
+ //
20
+ // So this polls discrete JSON reads and defines progress the only way that survives a
21
+ // log: the canonical state of the PR's checks actually changed. It carries no
22
+ // repainting clock, so a stationary phase is quiet and a real transition speaks.
23
+ //
24
+ // Streams:
25
+ // stdout exactly one terminal JSON verdict, at exit.
26
+ // stderr one compact JSON record per observed check-state change (`kind:"transition"`,
27
+ // `progress:true`), one `{"warning":"slow-ci-check"}` record per slow check run,
28
+ // one `{"kind":"transition","progress":false,...}` the first time the provider
29
+ // becomes unreadable, one more (`progress:false`) when it recovers without the
30
+ // checks having moved, and one closing `{"summary":true}` line after the verdict.
31
+ // Readability is health, not CI progress: a health record never moves the clock
32
+ // the stall bound is measured from.
33
+ //
34
+ // The record's projection is `state` — the canonical check set, sorted, with no clock
35
+ // fields. `at`, `elapsedMs` and `observations` are for humans reading the log; a
36
+ // background-task predicate watches `progress` and `state`, never log size or mtime.
37
+ //
38
+ // Exit codes: 0 checks passed / no checks / merged. 4 terminal but not deliverable
39
+ // (failing checks, stalled, unreadable, unknown check state, closed unmerged).
40
+ // 2 usage error.
41
+
42
+ import { execFileSync } from 'node:child_process'
43
+
44
+ const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
45
+ const FAILING = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'CANCELLED', 'STALE'])
46
+ const PENDING = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED', 'EXPECTED'])
47
+ const MERGE_GATE = new Set(['BLOCKED', 'BEHIND', 'DIRTY', 'DRAFT'])
48
+ const MERGEABLE = new Set(['CLEAN', 'HAS_HOOKS'])
49
+ const PR_FIELDS = 'state,mergedAt,mergeCommit,mergeStateStatus,statusCheckRollup,autoMergeRequest,url,headRefOid'
50
+ const MAX_MINUTES_CEILING = 240
51
+ const MAX_READ_BYTES = 8 * 1024 * 1024
52
+
53
+ function help() {
54
+ return `watch-checks --pr <number> [options]
55
+
56
+ Wait for required PR checks, falling back to all checks when none are required.
57
+ Read-only; never merges,
58
+ never comments, never edits a PR.
59
+
60
+ --pr <number> PR to watch (required)
61
+ --repo <owner>/<name> repository; defaults to the current checkout
62
+ --until checks|merged stop when the checks settle (default), or after proving
63
+ the PR actually merged. 'merged' is never a success without
64
+ a merge: a PR with no checks waits for the merge itself, and
65
+ an unrecognised mergeStateStatus ends as UNKNOWN
66
+ --interval-seconds <n> poll interval (default 45)
67
+ --stall-minutes <n> no-observable-change bound (default 15). Suspended while a
68
+ check is pending, and while an --until merged PR is
69
+ mergeable and awaiting the provider: running CI and a
70
+ provider-side merge are progress, not a stall. Also
71
+ bounds a required context that stays pending with no start
72
+ time (reason: check-never-started), suspended while other
73
+ checks are still running
74
+ --max-minutes <n> absolute ceiling (default 60, max ${MAX_MINUTES_CEILING})
75
+ --no-checks-minutes <n> with --until checks, a PR with no checks for this long has
76
+ no CI (default 5)
77
+ --slow-check-minutes <n> warn once per check run longer than this (default 5)
78
+ --read-retries <n> extra attempts inside one poll (default 2)
79
+ --unreadable-polls <n> consecutive unreadable polls before UNKNOWN (default 3)
80
+
81
+ Outcome values: CHECKS_PASSED, NO_CHECKS, MERGED, CHECKS_FAILED, CLOSED_UNMERGED,
82
+ STALLED, UNKNOWN.
83
+
84
+ Exit 0: CHECKS_PASSED, NO_CHECKS, MERGED. Exit 4: every other outcome. Exit 2: usage.`
85
+ }
86
+
87
+ function parseArgs(argv) {
88
+ const args = {
89
+ intervalSeconds: 45,
90
+ stallMinutes: 15,
91
+ maxMinutes: 60,
92
+ noChecksMinutes: 5,
93
+ slowCheckMinutes: 5,
94
+ readRetries: 2,
95
+ unreadablePolls: 3,
96
+ until: 'checks',
97
+ }
98
+ const numbers = {
99
+ '--interval-seconds': 'intervalSeconds',
100
+ '--stall-minutes': 'stallMinutes',
101
+ '--max-minutes': 'maxMinutes',
102
+ '--no-checks-minutes': 'noChecksMinutes',
103
+ '--slow-check-minutes': 'slowCheckMinutes',
104
+ '--read-retries': 'readRetries',
105
+ '--unreadable-polls': 'unreadablePolls',
106
+ }
107
+ for (let i = 0; i < argv.length; i += 1) {
108
+ const arg = argv[i]
109
+ if (arg === '--pr') args.pr = argv[++i]
110
+ else if (arg === '--repo') args.repo = argv[++i]
111
+ else if (arg === '--until') args.until = argv[++i]
112
+ else if (numbers[arg]) args[numbers[arg]] = Number(argv[++i])
113
+ else if (arg === '--help' || arg === '-h') args.help = true
114
+ else args.unknown = arg
115
+ }
116
+ return args
117
+ }
118
+
119
+ function validate(args) {
120
+ if (args.unknown) return `unknown argument ${args.unknown}`
121
+ if (!/^[1-9][0-9]*$/.test(String(args.pr ?? ''))) return '--pr must be a positive integer'
122
+ if (args.repo != null && !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(args.repo))) {
123
+ return '--repo must be owner/name'
124
+ }
125
+ if (args.until !== 'checks' && args.until !== 'merged') return '--until must be checks or merged'
126
+ for (const key of ['intervalSeconds', 'stallMinutes', 'maxMinutes', 'noChecksMinutes', 'slowCheckMinutes']) {
127
+ if (!Number.isFinite(args[key]) || args[key] <= 0) return `${key} must be a positive number`
128
+ }
129
+ for (const key of ['readRetries', 'unreadablePolls']) {
130
+ if (!Number.isInteger(args[key]) || args[key] < 0) return `${key} must be a non-negative integer`
131
+ }
132
+ if (args.unreadablePolls < 1) return '--unreadable-polls must be at least 1'
133
+ if (args.maxMinutes > MAX_MINUTES_CEILING) return `--max-minutes exceeds the ${MAX_MINUTES_CEILING}-minute ceiling`
134
+ return null
135
+ }
136
+
137
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
138
+
139
+ // A bounded per-read timeout: long enough for a slow-but-working API call, short enough
140
+ // that one hung read cannot outlive the bound that will fire STALLED on this poll.
141
+ function readTimeoutMs({ intervalSeconds, maxMinutes, elapsedMs }) {
142
+ const perRead = Math.max(1000, intervalSeconds * 3000)
143
+ const remaining = Math.max(1000, maxMinutes * 60_000 - elapsedMs)
144
+ return Math.min(perRead, remaining, 60_000)
145
+ }
146
+
147
+ function ghJson(ghArgs, timeoutMs, array = false) {
148
+ try {
149
+ const stdout = execFileSync('gh', ghArgs, {
150
+ encoding: 'utf8',
151
+ timeout: timeoutMs,
152
+ killSignal: 'SIGKILL',
153
+ maxBuffer: MAX_READ_BYTES,
154
+ stdio: ['ignore', 'pipe', 'pipe'],
155
+ env: { ...process.env, GH_PROMPT_DISABLED: '1' },
156
+ })
157
+ const data = JSON.parse(stdout)
158
+ if (!data || typeof data !== 'object' || Array.isArray(data) !== array) return { ok: false, error: 'gh returned an unexpected JSON shape' }
159
+ return { ok: true, data }
160
+ } catch (error) {
161
+ const stderr = String(error?.stderr ?? '').trim()
162
+ if (array && [1, 8].includes(error?.status)) {
163
+ // `gh pr checks` returns JSON with non-zero status for red/pending checks.
164
+ try {
165
+ const data = JSON.parse(String(error.stdout))
166
+ if (Array.isArray(data)) return { ok: true, data }
167
+ } catch {}
168
+ if (/no required checks/i.test(stderr)) return { ok: true, data: [] }
169
+ }
170
+ return { ok: false, error: stderr || String(error?.message ?? 'gh pr view failed') }
171
+ }
172
+ }
173
+
174
+ function readPr(args, timeoutMs) {
175
+ const ghArgs = ['pr', 'view', String(args.pr), '--json', PR_FIELDS]
176
+ if (args.repo) ghArgs.push('--repo', args.repo)
177
+ const pr = ghJson(ghArgs, Math.max(1, Math.floor(timeoutMs / 2)))
178
+ if (!pr.ok) return pr
179
+ const requiredArgs = ['pr', 'checks', String(args.pr), '--required', '--json', 'name']
180
+ if (args.repo) requiredArgs.push('--repo', args.repo)
181
+ const required = ghJson(requiredArgs, Math.max(1, Math.floor(timeoutMs / 2)), true)
182
+ if (!required.ok) return required
183
+ if (required.data.some((check) => typeof check?.name !== 'string' || !check.name)) {
184
+ return { ok: false, error: 'required checks returned an invalid name' }
185
+ }
186
+ const names = new Set(required.data.map((check) => check.name))
187
+ if (names.size > 0) {
188
+ const rollup = Array.isArray(pr.data.statusCheckRollup) ? pr.data.statusCheckRollup : []
189
+ const selected = rollup.filter((check) => names.has(check?.name ?? check?.context))
190
+ if ([...names].some((name) => !selected.some((check) => (check.name ?? check.context) === name))) {
191
+ return { ok: false, error: 'required checks and PR rollup disagree; retrying the observation' }
192
+ }
193
+ pr.data.statusCheckRollup = selected
194
+ }
195
+ return pr
196
+ }
197
+
198
+ // One rollup entry -> {name, phase}. `statusCheckRollup` mixes CheckRun (status +
199
+ // conclusion) and StatusContext (state only) shapes, and `gh` does not normalise them.
200
+ // An entry that matches neither fails closed as `unknown` rather than reading as pending
201
+ // or passing.
202
+ function normalizeCheck(entry) {
203
+ const name = entry?.name || entry?.context || 'unnamed-check'
204
+ const status = String(entry?.status ?? entry?.state ?? '').toUpperCase()
205
+ const conclusion = String(entry?.conclusion ?? '').toUpperCase()
206
+ let phase = 'unknown'
207
+ if (conclusion) phase = PASSING.has(conclusion) ? 'success' : FAILING.has(conclusion) ? 'failure' : 'unknown'
208
+ else if (PENDING.has(status)) phase = 'pending'
209
+ else if (PASSING.has(status)) phase = 'success'
210
+ else if (FAILING.has(status)) phase = 'failure'
211
+ const started = Date.parse(entry?.startedAt ?? entry?.createdAt ?? '')
212
+ const completed = Date.parse(entry?.completedAt ?? '')
213
+ return {
214
+ name,
215
+ phase,
216
+ status: status || null,
217
+ conclusion: conclusion || null,
218
+ startedMs: Number.isFinite(started) && started > 0 ? started : null,
219
+ completedMs: Number.isFinite(completed) && completed > 0 ? completed : null,
220
+ }
221
+ }
222
+
223
+ // The canonical projection. Sorted, no clocks, no per-poll drift — this is the value a
224
+ // change is defined against, and the value a background task can predicate on.
225
+ function project(data) {
226
+ const checks = (Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [])
227
+ .filter(Boolean)
228
+ .map(normalizeCheck)
229
+ .sort((a, b) => (a.name === b.name ? (a.phase < b.phase ? -1 : a.phase > b.phase ? 1 : 0) : a.name < b.name ? -1 : 1))
230
+ const names = (phase) => checks.filter((check) => check.phase === phase).map((check) => check.name)
231
+ return {
232
+ checks,
233
+ state: {
234
+ pr: {
235
+ state: data.state ?? null,
236
+ mergeStateStatus: data.mergeStateStatus ?? null,
237
+ mergedSha: data.mergeCommit?.oid ?? null,
238
+ autoMerge: Boolean(data.autoMergeRequest),
239
+ },
240
+ checks: checks.map(({ name, phase }) => ({ name, phase })),
241
+ failing: names('failure'),
242
+ pending: names('pending'),
243
+ unknown: names('unknown'),
244
+ },
245
+ }
246
+ }
247
+
248
+ const keyOf = (state) => JSON.stringify(state)
249
+
250
+ function record(payload) {
251
+ return JSON.stringify(payload)
252
+ }
253
+
254
+ function slowCheckRecords(checks, { thresholdMs, warned, nowMs, url }) {
255
+ const out = []
256
+ for (const check of checks) {
257
+ if (!check.startedMs) continue
258
+ const key = `${check.name}|${check.startedMs}`
259
+ if (warned.has(key)) continue
260
+ const durationMs = (check.completedMs ?? nowMs) - check.startedMs
261
+ if (durationMs <= thresholdMs) continue
262
+ warned.add(key)
263
+ out.push(record({
264
+ warning: 'slow-ci-check',
265
+ name: check.name,
266
+ phase: check.phase,
267
+ durationSec: Math.round(durationMs / 1000),
268
+ thresholdSec: Math.round(thresholdMs / 1000),
269
+ url,
270
+ }))
271
+ }
272
+ return out
273
+ }
274
+
275
+ async function main() {
276
+ const args = parseArgs(process.argv.slice(2))
277
+ if (args.help) {
278
+ console.log(help())
279
+ return 0
280
+ }
281
+ const invalid = validate(args)
282
+ if (invalid) {
283
+ console.error(`watch-checks: ${invalid}`)
284
+ return 2
285
+ }
286
+
287
+ const startedMs = Date.now()
288
+ const slowThresholdMs = args.slowCheckMinutes * 60_000
289
+ const warnedSlow = new Set()
290
+ let lastSemanticKey = null
291
+ let lastUnreadableKey = null
292
+ let lastState = null
293
+ let unreadablePolls = 0
294
+ let observations = 0
295
+ let transitions = 0
296
+ let lastError = null
297
+ let noChecksSince = null
298
+ let gateStreak = 0
299
+ let lastReadable = true
300
+ let changedAtMs = startedMs
301
+ let lastUrl = null
302
+ // A pending check that never acquires a start time is the "expected forever" shape: a
303
+ // required context GitHub will never run. Tracked by name so the bound outlives the
304
+ // check's own appearance and disappearance from the rollup.
305
+ const neverStartedSince = new Map()
306
+
307
+ const finish = (result, state) => {
308
+ const endMs = Date.now()
309
+ const verdict = {
310
+ outcome: result.outcome,
311
+ terminal: true,
312
+ reason: result.reason ?? null,
313
+ detail: result.detail ?? null,
314
+ until: args.until,
315
+ pr: {
316
+ state: state?.pr?.state ?? null,
317
+ mergeStateStatus: state?.pr?.mergeStateStatus ?? null,
318
+ mergedSha: state?.pr?.mergedSha ?? null,
319
+ url: lastUrl,
320
+ },
321
+ checks: state?.checks?.map(({ name, phase }) => ({ name, phase })) ?? [],
322
+ failing: state?.failing ?? [],
323
+ pending: state?.pending ?? [],
324
+ unknown: state?.unknown ?? [],
325
+ slowChecks: [...warnedSlow].map((key) => key.slice(0, key.lastIndexOf('|'))),
326
+ observations,
327
+ transitions,
328
+ elapsedMs: endMs - startedMs,
329
+ }
330
+ console.log(JSON.stringify(verdict, null, 2))
331
+ console.error(record({
332
+ summary: true,
333
+ outcome: verdict.outcome,
334
+ reason: verdict.reason,
335
+ failing: verdict.failing,
336
+ pending: verdict.pending,
337
+ slowChecks: verdict.slowChecks,
338
+ checks: verdict.checks.length,
339
+ }))
340
+ return result.exitCode
341
+ }
342
+
343
+ for (;;) {
344
+ observations += 1
345
+ const elapsedMs = Date.now() - startedMs
346
+ const timeoutMs = readTimeoutMs({ intervalSeconds: args.intervalSeconds, maxMinutes: args.maxMinutes, elapsedMs })
347
+
348
+ let read = null
349
+ for (let attempt = 0; attempt <= args.readRetries; attempt += 1) {
350
+ read = readPr(args, timeoutMs)
351
+ if (read.ok) break
352
+ if (attempt < args.readRetries) await sleepMs(Math.min(1000, args.intervalSeconds * 1000))
353
+ }
354
+
355
+ if (!read.ok) {
356
+ unreadablePolls += 1
357
+ lastError = read.error
358
+ // An unreadable provider is information, not progress, and it is not a failed
359
+ // check either: it must not settle a verdict except through its own bound. It never
360
+ // moves the change clock — a provider we cannot read says nothing about the checks.
361
+ const unreadableKey = `unreadable|${read.error}`
362
+ if (lastReadable || unreadableKey !== lastUnreadableKey) {
363
+ lastUnreadableKey = unreadableKey
364
+ lastReadable = false
365
+ transitions += 1
366
+ console.error(record({
367
+ kind: 'transition',
368
+ progress: false,
369
+ reason: 'provider-unreadable',
370
+ at: new Date().toISOString(),
371
+ state: { readable: false, error: read.error },
372
+ }))
373
+ }
374
+ if (unreadablePolls >= args.unreadablePolls) {
375
+ return finish({
376
+ outcome: 'UNKNOWN',
377
+ reason: 'unreadable',
378
+ detail: `gh pr view failed ${unreadablePolls} polls in a row: ${lastError}`,
379
+ exitCode: 4,
380
+ }, lastState)
381
+ }
382
+ if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
383
+ return finish({
384
+ outcome: 'UNKNOWN',
385
+ reason: 'max-minutes',
386
+ detail: `watch reached its ${args.maxMinutes}m ceiling while the provider was unreadable: ${lastError}`,
387
+ exitCode: 4,
388
+ }, lastState)
389
+ }
390
+ await sleepMs(args.intervalSeconds * 1000)
391
+ continue
392
+ }
393
+
394
+ unreadablePolls = 0
395
+ const { checks, state } = project(read.data)
396
+ lastUrl = read.data.url ?? null
397
+ lastState = state
398
+ const merged = state.pr.state === 'MERGED' || Boolean(read.data.mergedAt)
399
+ // An accepted auto-merge request means the provider owns the next step: the queue may
400
+ // hold the PR at BLOCKED/BEHIND while it does its own work, so that is not a human gate.
401
+ const providerHoldsMerge = state.pr.autoMerge === true
402
+
403
+ lastUnreadableKey = null
404
+ const wasReadable = lastReadable
405
+ lastReadable = true
406
+
407
+ const semanticKey = keyOf(state)
408
+ if (semanticKey !== lastSemanticKey) {
409
+ const reason = lastSemanticKey === null ? 'check-state-initial' : 'check-state'
410
+ lastSemanticKey = semanticKey
411
+ changedAtMs = Date.now()
412
+ transitions += 1
413
+ console.error(record({ kind: 'transition', progress: true, reason, at: new Date().toISOString(), state }))
414
+ } else if (!wasReadable) {
415
+ // Readable again and the checks are exactly where they were: health recovered, the
416
+ // semantic state did not change. Speak, but claim no progress and leave the change
417
+ // clock alone, so a provider outage cannot buy the PR another stall window.
418
+ transitions += 1
419
+ console.error(record({ kind: 'transition', progress: false, reason: 'provider-readable', at: new Date().toISOString(), state }))
420
+ }
421
+
422
+ for (const warning of slowCheckRecords(checks, { thresholdMs: slowThresholdMs, warned: warnedSlow, nowMs: Date.now(), url: lastUrl })) {
423
+ console.error(warning)
424
+ }
425
+
426
+ if (merged) return finish({ outcome: 'MERGED', reason: 'merged', exitCode: 0 }, state)
427
+ if (state.pr.state === 'CLOSED') {
428
+ return finish({ outcome: 'CLOSED_UNMERGED', reason: 'closed-without-merge', exitCode: 4 }, state)
429
+ }
430
+ // An unrecognised rollup entry is not a pass and not a pending check: stop and say
431
+ // which, rather than waiting for a state that will never settle.
432
+ if (state.unknown.length > 0) {
433
+ return finish({
434
+ outcome: 'UNKNOWN',
435
+ reason: 'unknown-check-state',
436
+ detail: `unrecognised check state: ${state.unknown.join(', ')}`,
437
+ exitCode: 4,
438
+ }, state)
439
+ }
440
+ if (state.failing.length > 0) {
441
+ return finish({
442
+ outcome: 'CHECKS_FAILED',
443
+ reason: 'checks-failed',
444
+ detail: state.failing.join(', '),
445
+ exitCode: 4,
446
+ }, state)
447
+ }
448
+
449
+ // "No checks" only answers the question `--until checks` asked. For `--until merged`
450
+ // an empty rollup means there is nothing to wait for on the CI side, and the merge
451
+ // itself is the whole remaining question: settling that as a success would report a
452
+ // green watch for a PR that never merged.
453
+ if (checks.length === 0 && args.until === 'checks') {
454
+ noChecksSince ??= Date.now()
455
+ if (Date.now() - noChecksSince >= args.noChecksMinutes * 60_000) {
456
+ return finish({
457
+ outcome: 'NO_CHECKS',
458
+ reason: 'no-checks-reported',
459
+ detail: `no checks reported for ${args.noChecksMinutes}m; verify repository CI policy before proceeding`,
460
+ exitCode: 0,
461
+ }, state)
462
+ }
463
+ } else if (checks.length > 0) {
464
+ noChecksSince = null
465
+ }
466
+
467
+ // Never-started contexts: a required context GitHub will never run is the "expected
468
+ // forever" shape, so bound it instead of waiting for a settlement that cannot come.
469
+ // While sibling checks are actually running the provider has simply not got to this
470
+ // one yet, so the diagnosis is suspended — and its clock is cleared, so the bound
471
+ // measures sustained no-CI time instead of time that belongs to a running suite.
472
+ const ciActivelyRunning = checks.some((check) => check.phase === 'pending' && check.startedMs)
473
+ if (ciActivelyRunning) {
474
+ neverStartedSince.clear()
475
+ } else {
476
+ const notStarted = new Set(checks.filter((check) => check.phase === 'pending' && !check.startedMs).map((check) => check.name))
477
+ for (const name of [...neverStartedSince.keys()]) if (!notStarted.has(name)) neverStartedSince.delete(name)
478
+ for (const name of notStarted) if (!neverStartedSince.has(name)) neverStartedSince.set(name, Date.now())
479
+ const stuck = [...neverStartedSince].filter(([, sinceMs]) => Date.now() - sinceMs >= args.stallMinutes * 60_000).map(([name]) => name)
480
+ if (stuck.length > 0) {
481
+ return finish({
482
+ outcome: 'STALLED',
483
+ reason: 'check-never-started',
484
+ detail: `${stuck.join(', ')} reported pending with no start time for ${args.stallMinutes}m`,
485
+ exitCode: 4,
486
+ }, state)
487
+ }
488
+ }
489
+
490
+ // Settled checks. An empty rollup counts as settled only for `--until merged`, where it
491
+ // means "nothing left to run" rather than "the answer is NO_CHECKS".
492
+ const settled = checks.length > 0 ? state.pending.length === 0 : args.until === 'merged'
493
+ if (settled && args.until === 'checks') return finish({ outcome: 'CHECKS_PASSED', reason: 'checks-passed', exitCode: 0 }, state)
494
+
495
+ // Awaiting a provider-side merge. An accepted auto-merge request outranks the status:
496
+ // the queue parks a queued PR at BLOCKED while it works, so that is not a human gate.
497
+ // A merge that cannot happen (review, stale base, conflict, draft) is a hand-back, not
498
+ // a wait to the ceiling, and a mergeStateStatus this tool does not recognise is never
499
+ // read as "mergeable": it is an explicit unknown, so nobody waits on a status the tool
500
+ // cannot name. One read is suspicion, so the hand-back needs two consecutive reads.
501
+ const gateStatus = String(state.pr.mergeStateStatus ?? '').toUpperCase()
502
+ const mergeDisposition = providerHoldsMerge
503
+ ? 'provider'
504
+ : MERGEABLE.has(gateStatus)
505
+ ? 'mergeable'
506
+ : MERGE_GATE.has(gateStatus)
507
+ ? 'gate'
508
+ : 'unknown'
509
+ const mergeAwaitBlocked = args.until === 'merged' && settled
510
+ && mergeDisposition !== 'provider' && mergeDisposition !== 'mergeable'
511
+ if (mergeAwaitBlocked) {
512
+ gateStreak += 1
513
+ if (gateStreak >= 2) {
514
+ if (mergeDisposition === 'unknown') {
515
+ return finish({
516
+ outcome: 'UNKNOWN',
517
+ reason: 'unknown-mergeability',
518
+ detail: `checks are settled but mergeStateStatus ${JSON.stringify(state.pr.mergeStateStatus)} is not a state this tool knows`,
519
+ exitCode: 4,
520
+ }, state)
521
+ }
522
+ return finish({
523
+ outcome: 'STALLED',
524
+ reason: `merge-gate:${gateStatus.toLowerCase()}`,
525
+ detail: `checks are green and the merge cannot proceed: mergeStateStatus ${state.pr.mergeStateStatus}`,
526
+ exitCode: 4,
527
+ }, state)
528
+ }
529
+ } else {
530
+ gateStreak = 0
531
+ }
532
+
533
+ const awaitingProvider = args.until === 'merged' && settled
534
+ && (mergeDisposition === 'provider' || mergeDisposition === 'mergeable')
535
+ const ciRunning = state.pending.length > 0
536
+
537
+ if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
538
+ return finish({
539
+ outcome: 'STALLED',
540
+ reason: 'max-minutes',
541
+ detail: `watch reached its ${args.maxMinutes}m ceiling with the provider still reporting ${state.pr.state ?? 'an unknown state'}`,
542
+ exitCode: 4,
543
+ }, state)
544
+ }
545
+ if (!ciRunning && !awaitingProvider && Date.now() - changedAtMs >= args.stallMinutes * 60_000) {
546
+ return finish({
547
+ outcome: 'STALLED',
548
+ reason: 'stall-bound',
549
+ detail: `no observable check-state change for ${args.stallMinutes}m`,
550
+ exitCode: 4,
551
+ }, state)
552
+ }
553
+
554
+ await sleepMs(args.intervalSeconds * 1000)
555
+ }
556
+ }
557
+
558
+ process.exitCode = await main().catch((error) => {
559
+ console.error(record({ error: 'watch-checks-failed', detail: String(error?.message ?? error) }))
560
+ return 4
561
+ })
@@ -5,7 +5,7 @@ description: >
5
5
  project management. Use when configuring GCP resources, managing service
6
6
  accounts, setting up IAP access, configuring VPC networks, managing secrets,
7
7
  or debugging cloud infrastructure.
8
- release-version: 0.50.1
8
+ release-version: 0.50.2
9
9
  ---
10
10
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
11
11
  # Google Cloud Platform (GCP) Skill
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: mergify
3
3
  description: "Mergify merge queue and stacked PRs (`mergify stack`): config, rules, queue status, delivery. Use whenever a merge queue or stack is involved, including plain asks ('watch #123 till it merges', 'where is #412 in the queue', 'push this stack'): `gh pr view`/`gh pr checks` cannot see a dequeue; use `mergify queue show` and the bundled watcher. Also: a hook saying 'use mergify stack push', a missing Change-Id; authoring or debugging `.mergify.yml`, queue rules, merge protections, batch size, the ruleset making merges queue-only; enqueue policy (`@mergifyio queue` vs `auto_merge_conditions`); a push dequeuing a queued PR; bypass actors; freezing a branch. Failures: a PR stuck, queued but never merging, or merged unqueued; CI green on GitHub but unmet in Mergify; a batch going red or bisecting; a queue check missing rather than failing. Outranks the vendor mergify-* skills. NOT for pushing a PR in a repo without stack hooks (github-flow), workflow YAML (github-actions), or GCP deploy pipelines (cloud-deploy)."
4
- release-version: 0.50.1
4
+ release-version: 0.50.2
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Mergify Merge Queue
@@ -23,7 +23,7 @@ Requires mergify CLI **2026.10.5 or newer** ([stacks.md](references/stacks.md#cl
23
23
  | **Push, open, or update a PR in a stack repo** (hook says "Use 'mergify stack push'", `Change-Id` missing) | [`references/stacks.md`](references/stacks.md) | Push with `scripts/stack-push.mjs`. One commit = one PR; amend to fix. Never `--no-verify`. |
24
24
  | A stacked PR gets no CI, conflicts after its parent lands, or is dequeued "could not be retargeted onto … `main`" | [`references/configuration.md`](references/configuration.md#stack-ready), then [`references/traps.md`](references/traps.md) | Make the repo stack-ready (native stacks: Mergify's ruleset bypass `exempt`; `Depends-On` stacks also need PR CI on every base, commits kept, auto-delete head branches); then `mergify stack push --force-rebase` for a stuck tip. |
25
25
  | Author or change `.mergify.yml` | [`references/configuration.md`](references/configuration.md) | [Rule 1](#rule-1--fetch-the-docs-before-writing-config): never write config from memory; finish with `validate` **and** `simulate`. |
26
- | **Watch a PR through the queue** | [`references/watch-contract.md`](references/watch-contract.md) | **[Rule 2](#rule-2--never-hand-roll-a-watcher): run the bundled watcher, never hand-roll one.** A watch observes a *progression*, not one state. |
26
+ | **Watch a PR through the queue** | [`references/watch-contract.md`](references/watch-contract.md) | **[Rule 2](#rule-2--never-hand-roll-a-watcher): run the bundled watcher, never hand-roll one.** A watch observes a *progression*, not one state. It emits the initial snapshot and delivery/readability transitions; repeated identical polls stay quiet. |
27
27
  | **The queue feels slow, or is it worth it?** | [`references/diagnosis.md`](references/diagnosis.md#measuring-a-slow-queue) | Measure first-try merge rate and same-SHA requeues from the event log, then fix the flaky queue check. Never move a queue-only check onto the PR lane, and never rerun CI on push to `main` ([rules](references/configuration.md#the-queue-run-is-the-integration-run)). |
28
28
  | Check status — "where is my PR?", "why is it stuck?" | [`references/diagnosis.md`](references/diagnosis.md#querying-pr-and-queue-status) | `mergify queue show <number> -v` names the single blocking condition (bare number, never a URL; the CLI takes `--repository <owner/repo>`, never `--repo`; the bundled watcher below takes `--repo`); nothing else in the toolchain does. `gh pr checks` is CI truth, never queue truth. |
29
29
  | A PR merged without ever being queued | [`references/branch-protection.md`](references/branch-protection.md) | Make the queue mandatory with the ruleset's `update` rule; a required status check alone does not. |