@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.
- package/package.json +2 -2
- package/skills/cloud-build/SKILL.md +1 -1
- package/skills/cloud-debug/SKILL.md +1 -1
- package/skills/cloud-deploy/SKILL.md +1 -1
- package/skills/docker/SKILL.md +1 -1
- package/skills/github-actions/SKILL.md +4 -2
- package/skills/github-actions/references/debugging.md +21 -0
- package/skills/github-actions/references/post-deploy-and-publishing.md +7 -0
- package/skills/github-actions/scripts/watch-run.mjs +511 -0
- package/skills/github-flow/SKILL.md +35 -9
- package/skills/github-flow/scripts/watch-checks.mjs +561 -0
- package/skills/google-cloud/SKILL.md +1 -1
- package/skills/mergify/SKILL.md +2 -2
- package/skills/mergify/references/diagnosis.md +6 -1
- package/skills/mergify/references/watch-contract.md +10 -7
- package/skills/mergify/scripts/watch-pr-delivery-core.mjs +15 -30
- package/skills/podman/SKILL.md +1 -1
|
@@ -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.
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
69
|
-
|
|
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
|
|
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.
|
|
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
|
package/skills/mergify/SKILL.md
CHANGED
|
@@ -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.
|
|
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. |
|