@1aboveio/skills 1.0.21 → 1.0.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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +150 -147
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/maestro/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +4 -2
- package/skills/cicd/github-actions/references/debugging.md +21 -0
- package/skills/cicd/github-actions/references/post-deploy-and-publishing.md +7 -0
- package/skills/cicd/github-actions/scripts/watch-run.mjs +511 -0
- package/skills/cicd/github-flow/SKILL.md +35 -9
- package/skills/cicd/github-flow/scripts/watch-checks.mjs +561 -0
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +2 -2
- package/skills/cicd/mergify/references/diagnosis.md +6 -1
- package/skills/cicd/mergify/references/watch-contract.md +10 -7
- package/skills/cicd/mergify/scripts/watch-pr-delivery-core.mjs +15 -30
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +2 -1
- package/skills/engineering/harness-runtime/contracts.md +2 -0
- package/skills/engineering/harness-runtime/references/subagent-sessions.md +75 -0
- package/skills/engineering/resolve-issues/SKILL.md +2 -2
- package/skills/engineering/resolve-issues/references/ci.md +2 -4
- package/skills/engineering/resolve-issues/references/delivery-progress.md +1 -1
- package/skills/engineering/resolve-issues/references/done.md +3 -6
- package/skills/engineering/resolve-issues/references/expire.md +2 -2
- package/skills/engineering/resolve-issues/references/implementation.md +52 -41
- package/skills/engineering/resolve-issues/references/review.md +18 -19
- package/skills/engineering/resolve-issues/references/shared-modules.md +7 -1
- package/skills/engineering/resolve-issues/references/task-plan.md +18 -10
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +42 -32
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/resolve-release/references/publish.md +12 -4
- package/skills/engineering/resolve-release/references/workspace-npm.md +6 -4
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// watch-run — wait for one GitHub Actions run and report only the job/step transitions it
|
|
3
|
+
// actually makes. Read-only: it only reads `gh run view`.
|
|
4
|
+
//
|
|
5
|
+
// node watch-run.mjs --run <id> [--repo <owner>/<name>]
|
|
6
|
+
// [--interval-seconds 30] [--stall-minutes 15] [--max-minutes 60]
|
|
7
|
+
// [--slow-step-minutes 5] [--read-retries 2] [--unreadable-polls 3]
|
|
8
|
+
//
|
|
9
|
+
// Why this exists instead of `gh run watch`:
|
|
10
|
+
//
|
|
11
|
+
// `gh run watch` repaints a status block — and an elapsed clock — every 30 seconds. To
|
|
12
|
+
// anything that reads output activity as progress (a log tail, a file mtime, an agent
|
|
13
|
+
// skim), that repaint is indistinguishable from a run doing work, and the run's own steps
|
|
14
|
+
// are exactly what stops being visible: a job wedged in one step for twenty minutes reads
|
|
15
|
+
// the same as a job moving.
|
|
16
|
+
//
|
|
17
|
+
// So this polls discrete JSON reads and treats only a change in the canonical run state as
|
|
18
|
+
// progress. Each change is reported as the job/step transition that caused it, with the
|
|
19
|
+
// duration of the step that moved, and a slow step is called out once while it is still
|
|
20
|
+
// running rather than discovered afterwards.
|
|
21
|
+
//
|
|
22
|
+
// Streams:
|
|
23
|
+
// stdout exactly one terminal JSON verdict, at exit.
|
|
24
|
+
// stderr one compact JSON record per observed run-state change (`kind:"transition"`,
|
|
25
|
+
// `progress:true`, with a `changes` list of job/step transitions), one
|
|
26
|
+
// `{"warning":"slow-ci-step"}` per slow job or step, one provider-unreadable
|
|
27
|
+
// record when reads fail, and one closing `{"summary":true}` line.
|
|
28
|
+
//
|
|
29
|
+
// The record's projection is `state` — run status/conclusion plus each job's status and
|
|
30
|
+
// current step, no clocks. `at`, `elapsedMs` and `observations` are for humans; a
|
|
31
|
+
// background-task predicate watches `progress`/`state`, never log size or mtime.
|
|
32
|
+
//
|
|
33
|
+
// Exit codes: 0 the run succeeded (or was neutral/skipped). 4 failed, cancelled, timed out,
|
|
34
|
+
// stalled, unreadable, or an unknown run state. 2 usage error.
|
|
35
|
+
|
|
36
|
+
import { execFileSync } from 'node:child_process'
|
|
37
|
+
|
|
38
|
+
const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
|
|
39
|
+
const STATUSES = new Set(['REQUESTED', 'WAITING', 'PENDING', 'QUEUED', 'IN_PROGRESS', 'COMPLETED'])
|
|
40
|
+
const CONCLUSIONS = new Set([...PASSING, 'FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE'])
|
|
41
|
+
const RUN_FIELDS = 'status,conclusion,jobs,workflowName,headSha,url,attempt'
|
|
42
|
+
const MAX_MINUTES_CEILING = 240
|
|
43
|
+
const MAX_READ_BYTES = 16 * 1024 * 1024
|
|
44
|
+
const MAX_REPORTED_CHANGES = 25
|
|
45
|
+
|
|
46
|
+
function help() {
|
|
47
|
+
return `watch-run --run <id> [options]
|
|
48
|
+
|
|
49
|
+
Wait for one Actions run, reporting job and step transitions. Read-only; it never
|
|
50
|
+
re-runs, cancels, dispatches, or edits anything.
|
|
51
|
+
|
|
52
|
+
--run <id> run to watch (required)
|
|
53
|
+
--repo <owner>/<name> repository; defaults to the current checkout
|
|
54
|
+
--interval-seconds <n> poll interval (default 30)
|
|
55
|
+
--stall-minutes <n> no-observable-change bound while nothing is in progress
|
|
56
|
+
(default 15); suspended while a job or step is running
|
|
57
|
+
--max-minutes <n> absolute ceiling (default 60, max ${MAX_MINUTES_CEILING})
|
|
58
|
+
--slow-step-minutes <n> warn once per job or step running longer (default 5)
|
|
59
|
+
--read-retries <n> extra attempts inside one poll (default 2)
|
|
60
|
+
--unreadable-polls <n> consecutive unreadable polls before UNKNOWN (default 3)
|
|
61
|
+
|
|
62
|
+
Outcome values: SUCCESS, NEUTRAL, FAILED, CANCELLED, STALLED, UNKNOWN.
|
|
63
|
+
|
|
64
|
+
Exit 0: SUCCESS, NEUTRAL. Exit 4: every other outcome. Exit 2: usage.`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseArgs(argv) {
|
|
68
|
+
const args = {
|
|
69
|
+
intervalSeconds: 30,
|
|
70
|
+
stallMinutes: 15,
|
|
71
|
+
maxMinutes: 60,
|
|
72
|
+
slowStepMinutes: 5,
|
|
73
|
+
readRetries: 2,
|
|
74
|
+
unreadablePolls: 3,
|
|
75
|
+
}
|
|
76
|
+
const numbers = {
|
|
77
|
+
'--interval-seconds': 'intervalSeconds',
|
|
78
|
+
'--stall-minutes': 'stallMinutes',
|
|
79
|
+
'--max-minutes': 'maxMinutes',
|
|
80
|
+
'--slow-step-minutes': 'slowStepMinutes',
|
|
81
|
+
'--read-retries': 'readRetries',
|
|
82
|
+
'--unreadable-polls': 'unreadablePolls',
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
85
|
+
const arg = argv[i]
|
|
86
|
+
if (arg === '--run') args.run = argv[++i]
|
|
87
|
+
else if (arg === '--repo') args.repo = argv[++i]
|
|
88
|
+
else if (numbers[arg]) args[numbers[arg]] = Number(argv[++i])
|
|
89
|
+
else if (arg === '--help' || arg === '-h') args.help = true
|
|
90
|
+
else args.unknown = arg
|
|
91
|
+
}
|
|
92
|
+
return args
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validate(args) {
|
|
96
|
+
if (args.unknown) return `unknown argument ${args.unknown}`
|
|
97
|
+
if (!/^[1-9][0-9]*$/.test(String(args.run ?? ''))) return '--run must be a positive integer'
|
|
98
|
+
if (args.repo != null && !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(args.repo))) {
|
|
99
|
+
return '--repo must be owner/name'
|
|
100
|
+
}
|
|
101
|
+
for (const key of ['intervalSeconds', 'stallMinutes', 'maxMinutes', 'slowStepMinutes']) {
|
|
102
|
+
if (!Number.isFinite(args[key]) || args[key] <= 0) return `${key} must be a positive number`
|
|
103
|
+
}
|
|
104
|
+
for (const key of ['readRetries', 'unreadablePolls']) {
|
|
105
|
+
if (!Number.isInteger(args[key]) || args[key] < 0) return `${key} must be a non-negative integer`
|
|
106
|
+
}
|
|
107
|
+
if (args.unreadablePolls < 1) return '--unreadable-polls must be at least 1'
|
|
108
|
+
if (args.maxMinutes > MAX_MINUTES_CEILING) return `--max-minutes exceeds the ${MAX_MINUTES_CEILING}-minute ceiling`
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
113
|
+
|
|
114
|
+
function readTimeoutMs({ intervalSeconds, maxMinutes, elapsedMs }) {
|
|
115
|
+
const perRead = Math.max(1000, intervalSeconds * 3000)
|
|
116
|
+
const remaining = Math.max(1000, maxMinutes * 60_000 - elapsedMs)
|
|
117
|
+
return Math.min(perRead, remaining, 60_000)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ghJson(ghArgs, timeoutMs) {
|
|
121
|
+
try {
|
|
122
|
+
const stdout = execFileSync('gh', ghArgs, {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
timeout: timeoutMs,
|
|
125
|
+
killSignal: 'SIGKILL',
|
|
126
|
+
maxBuffer: MAX_READ_BYTES,
|
|
127
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
env: { ...process.env, GH_PROMPT_DISABLED: '1' },
|
|
129
|
+
})
|
|
130
|
+
const data = JSON.parse(stdout)
|
|
131
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return { ok: false, error: 'gh run view returned no object' }
|
|
132
|
+
return { ok: true, data }
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const stderr = String(error?.stderr ?? '').trim()
|
|
135
|
+
return { ok: false, error: stderr || String(error?.message ?? 'gh run view failed') }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readRun(args, timeoutMs) {
|
|
140
|
+
const ghArgs = ['run', 'view', String(args.run), '--json', RUN_FIELDS]
|
|
141
|
+
if (args.repo) ghArgs.push('--repo', args.repo)
|
|
142
|
+
return ghJson(ghArgs, timeoutMs)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const upper = (value) => String(value ?? '').toUpperCase()
|
|
146
|
+
const timeMs = (value) => {
|
|
147
|
+
const parsed = Date.parse(value ?? '')
|
|
148
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeSteps(job) {
|
|
152
|
+
return (Array.isArray(job?.steps) ? job.steps : []).map((step, index) => ({
|
|
153
|
+
index: Number.isFinite(step?.number) ? step.number : index + 1,
|
|
154
|
+
name: step?.name || `step-${index + 1}`,
|
|
155
|
+
status: upper(step?.status) || null,
|
|
156
|
+
conclusion: upper(step?.conclusion) || null,
|
|
157
|
+
startedMs: timeMs(step?.startedAt),
|
|
158
|
+
completedMs: timeMs(step?.completedAt),
|
|
159
|
+
}))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeJob(job) {
|
|
163
|
+
const steps = normalizeSteps(job)
|
|
164
|
+
const current = steps.filter((step) => step.status === 'IN_PROGRESS').at(-1)
|
|
165
|
+
?? steps.filter((step) => step.status !== null).at(-1)
|
|
166
|
+
?? null
|
|
167
|
+
return {
|
|
168
|
+
id: String(job?.databaseId ?? job?.url ?? job?.name ?? 'unnamed-job'),
|
|
169
|
+
name: job?.name || 'unnamed-job',
|
|
170
|
+
status: upper(job?.status) || null,
|
|
171
|
+
conclusion: upper(job?.conclusion) || null,
|
|
172
|
+
startedMs: timeMs(job?.startedAt),
|
|
173
|
+
completedMs: timeMs(job?.completedAt),
|
|
174
|
+
url: typeof job?.url === 'string' && job.url.startsWith('http') ? job.url : null,
|
|
175
|
+
steps,
|
|
176
|
+
current,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The canonical projection: run status/conclusion plus each job's status and the step it is
|
|
181
|
+
// on. Sorted by job name, no clocks — this is what "the run changed" means, and what a
|
|
182
|
+
// background task can predicate on.
|
|
183
|
+
function project(data) {
|
|
184
|
+
const jobs = (Array.isArray(data.jobs) ? data.jobs : [])
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.map(normalizeJob)
|
|
187
|
+
.sort((a, b) => {
|
|
188
|
+
const key = (job) => JSON.stringify([job.name, job.id, job.status, job.conclusion,
|
|
189
|
+
job.steps.map((step) => [step.index, step.name, step.status, step.conclusion])])
|
|
190
|
+
return key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0
|
|
191
|
+
})
|
|
192
|
+
const state = {
|
|
193
|
+
run: {
|
|
194
|
+
attempt: data.attempt ?? null,
|
|
195
|
+
status: upper(data.status) || null,
|
|
196
|
+
conclusion: upper(data.conclusion) || null,
|
|
197
|
+
workflow: data.workflowName ?? null,
|
|
198
|
+
sha: data.headSha ?? null,
|
|
199
|
+
},
|
|
200
|
+
jobs: jobs.map((job) => ({
|
|
201
|
+
id: job.id,
|
|
202
|
+
name: job.name,
|
|
203
|
+
status: job.status,
|
|
204
|
+
conclusion: job.conclusion,
|
|
205
|
+
step: job.current?.name ?? null,
|
|
206
|
+
stepStatus: job.current?.status ?? null,
|
|
207
|
+
})),
|
|
208
|
+
running: jobs.flatMap((job) => job.steps
|
|
209
|
+
.filter((step) => step.status === 'IN_PROGRESS')
|
|
210
|
+
.map((step) => `${job.name} / ${step.name}`)),
|
|
211
|
+
waiting: jobs.filter((job) => job.status !== 'COMPLETED' && job.status !== 'IN_PROGRESS')
|
|
212
|
+
.map((job) => `${job.name}:${job.status ?? 'unknown'}`),
|
|
213
|
+
}
|
|
214
|
+
// The change key is finer than the emitted projection: a step moving is a change even
|
|
215
|
+
// when the job and run statuses are unchanged. It is never emitted — only compared.
|
|
216
|
+
const key = JSON.stringify([
|
|
217
|
+
state.run,
|
|
218
|
+
jobs.map((job) => [job.id, job.name, job.status, job.conclusion, job.steps.map((step) => [step.index, step.name, step.status, step.conclusion])]),
|
|
219
|
+
])
|
|
220
|
+
return { jobs, state, key }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const label = (status, conclusion) => conclusion || status || 'unknown'
|
|
224
|
+
|
|
225
|
+
// The transitions between two projections, as lines a human can read in a log tail.
|
|
226
|
+
function describeChanges(previousState, previousJobs, { jobs, state }) {
|
|
227
|
+
const changes = []
|
|
228
|
+
if (previousState && previousJobs) {
|
|
229
|
+
if (previousState.run.status !== state.run.status || previousState.run.conclusion !== state.run.conclusion) {
|
|
230
|
+
changes.push(`run: ${label(previousState.run.status, previousState.run.conclusion)} -> ${label(state.run.status, state.run.conclusion)}`)
|
|
231
|
+
}
|
|
232
|
+
if (previousState.run.attempt !== state.run.attempt) {
|
|
233
|
+
changes.push(`run attempt: ${previousState.run.attempt} -> ${state.run.attempt}`)
|
|
234
|
+
}
|
|
235
|
+
const before = new Map(previousJobs.map((job) => [job.id, job]))
|
|
236
|
+
for (const job of jobs) {
|
|
237
|
+
const prior = before.get(job.id)
|
|
238
|
+
if (!prior) changes.push(`job ${job.name}: appeared (${label(job.status, job.conclusion)})`)
|
|
239
|
+
else if (prior.status !== job.status || prior.conclusion !== job.conclusion) {
|
|
240
|
+
changes.push(`job ${job.name}: ${label(prior.status, prior.conclusion)} -> ${label(job.status, job.conclusion)}`)
|
|
241
|
+
}
|
|
242
|
+
const priorSteps = new Map((prior?.steps ?? []).map((step) => [step.index, label(step.status, step.conclusion)]))
|
|
243
|
+
for (const step of job.steps) {
|
|
244
|
+
const was = priorSteps.get(step.index)
|
|
245
|
+
const now = label(step.status, step.conclusion)
|
|
246
|
+
if (!was) changes.push(`step ${job.name} / ${step.name}: appeared (${now})`)
|
|
247
|
+
else if (was !== now) changes.push(`step ${job.name} / ${step.name}: ${was} -> ${now}`)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (previousState && changes.length === 0) changes.push('run state changed (job, step, or run metadata)')
|
|
252
|
+
const extra = Math.max(0, changes.length - MAX_REPORTED_CHANGES)
|
|
253
|
+
return {
|
|
254
|
+
changes: changes.slice(0, MAX_REPORTED_CHANGES),
|
|
255
|
+
changesOmitted: extra,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// One warning per job run and per step run, emitted while they are still running.
|
|
260
|
+
function slowRecords(jobs, { thresholdMs, warned, nowMs, runUrl }) {
|
|
261
|
+
const out = []
|
|
262
|
+
const emit = (kind, key, { durationMs, ...fields }) => {
|
|
263
|
+
if (warned.has(key) || durationMs <= thresholdMs) return
|
|
264
|
+
warned.add(key)
|
|
265
|
+
out.push(JSON.stringify({
|
|
266
|
+
warning: 'slow-ci-step',
|
|
267
|
+
kind,
|
|
268
|
+
durationSec: Math.round(durationMs / 1000),
|
|
269
|
+
thresholdSec: Math.round(thresholdMs / 1000),
|
|
270
|
+
...fields,
|
|
271
|
+
}))
|
|
272
|
+
}
|
|
273
|
+
for (const job of jobs) {
|
|
274
|
+
const jobEnd = job.completedMs ?? nowMs
|
|
275
|
+
if (job.startedMs) {
|
|
276
|
+
emit('job', `job|${job.name}|${job.startedMs}`, {
|
|
277
|
+
job: job.name,
|
|
278
|
+
step: null,
|
|
279
|
+
status: label(job.status, job.conclusion),
|
|
280
|
+
durationMs: jobEnd - job.startedMs,
|
|
281
|
+
url: job.url ?? runUrl,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
for (const step of job.steps) {
|
|
285
|
+
if (!step.startedMs) continue
|
|
286
|
+
emit('step', `step|${job.name}|${step.name}|${step.startedMs}`, {
|
|
287
|
+
job: job.name,
|
|
288
|
+
step: step.name,
|
|
289
|
+
status: label(step.status, step.conclusion),
|
|
290
|
+
durationMs: (step.completedMs ?? nowMs) - step.startedMs,
|
|
291
|
+
url: job.url ?? runUrl,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function failureDetail(jobs) {
|
|
299
|
+
const failed = jobs.filter((job) => job.conclusion && !PASSING.has(job.conclusion))
|
|
300
|
+
const named = failed.map((job) => {
|
|
301
|
+
const steps = job.steps.filter((step) => step.conclusion && !PASSING.has(step.conclusion)).map((step) => step.name)
|
|
302
|
+
return steps.length > 0 ? `${job.name} (${job.conclusion}: ${steps.join(', ')})` : `${job.name} (${job.conclusion})`
|
|
303
|
+
})
|
|
304
|
+
return {
|
|
305
|
+
failedJobs: named,
|
|
306
|
+
detail: named.length > 0 ? named.join('; ') : 'the run finished without a conclusion',
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function main() {
|
|
311
|
+
const args = parseArgs(process.argv.slice(2))
|
|
312
|
+
if (args.help) {
|
|
313
|
+
console.log(help())
|
|
314
|
+
return 0
|
|
315
|
+
}
|
|
316
|
+
const invalid = validate(args)
|
|
317
|
+
if (invalid) {
|
|
318
|
+
console.error(`watch-run: ${invalid}`)
|
|
319
|
+
return 2
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const startedMs = Date.now()
|
|
323
|
+
const slowThresholdMs = args.slowStepMinutes * 60_000
|
|
324
|
+
const warnedSlow = new Set()
|
|
325
|
+
let lastKey = null
|
|
326
|
+
let lastState = null
|
|
327
|
+
let previousState = null
|
|
328
|
+
let previousJobs = null
|
|
329
|
+
let lastRunUrl = null
|
|
330
|
+
let unreadablePolls = 0
|
|
331
|
+
let observations = 0
|
|
332
|
+
let transitions = 0
|
|
333
|
+
let lastError = null
|
|
334
|
+
let changedAtMs = startedMs
|
|
335
|
+
let lastReadable = true
|
|
336
|
+
let lastUnreadableKey = null
|
|
337
|
+
let latestJobs = []
|
|
338
|
+
|
|
339
|
+
const finish = (result) => {
|
|
340
|
+
const endMs = Date.now()
|
|
341
|
+
const failures = failureDetail(latestJobs)
|
|
342
|
+
const verdict = {
|
|
343
|
+
outcome: result.outcome,
|
|
344
|
+
terminal: true,
|
|
345
|
+
reason: result.reason ?? null,
|
|
346
|
+
detail: result.detail ?? null,
|
|
347
|
+
run: {
|
|
348
|
+
id: Number(args.run),
|
|
349
|
+
attempt: lastState?.run?.attempt ?? null,
|
|
350
|
+
status: lastState?.run?.status ?? null,
|
|
351
|
+
conclusion: lastState?.run?.conclusion ?? null,
|
|
352
|
+
workflow: lastState?.run?.workflow ?? null,
|
|
353
|
+
sha: lastState?.run?.sha ?? null,
|
|
354
|
+
url: lastRunUrl,
|
|
355
|
+
},
|
|
356
|
+
jobs: lastState?.jobs ?? [],
|
|
357
|
+
failedJobs: failures.failedJobs,
|
|
358
|
+
slowJobs: [...warnedSlow].filter((key) => key.startsWith('job|')).map((key) => key.slice(4, key.lastIndexOf('|'))),
|
|
359
|
+
slowSteps: [...warnedSlow].filter((key) => key.startsWith('step|')).map((key) => {
|
|
360
|
+
const [, job, ...rest] = key.split('|')
|
|
361
|
+
return `${job} / ${rest.slice(0, -1).join('|')}`
|
|
362
|
+
}),
|
|
363
|
+
observations,
|
|
364
|
+
transitions,
|
|
365
|
+
elapsedMs: endMs - startedMs,
|
|
366
|
+
}
|
|
367
|
+
console.log(JSON.stringify(verdict, null, 2))
|
|
368
|
+
console.error(JSON.stringify({
|
|
369
|
+
summary: true,
|
|
370
|
+
outcome: verdict.outcome,
|
|
371
|
+
reason: verdict.reason,
|
|
372
|
+
failedJobs: verdict.failedJobs,
|
|
373
|
+
slowSteps: verdict.slowSteps,
|
|
374
|
+
running: lastState?.running ?? [],
|
|
375
|
+
}))
|
|
376
|
+
return result.exitCode
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (;;) {
|
|
380
|
+
observations += 1
|
|
381
|
+
const elapsedMs = Date.now() - startedMs
|
|
382
|
+
const timeoutMs = readTimeoutMs({ intervalSeconds: args.intervalSeconds, maxMinutes: args.maxMinutes, elapsedMs })
|
|
383
|
+
|
|
384
|
+
let read = null
|
|
385
|
+
for (let attempt = 0; attempt <= args.readRetries; attempt += 1) {
|
|
386
|
+
read = readRun(args, timeoutMs)
|
|
387
|
+
if (read.ok) break
|
|
388
|
+
if (attempt < args.readRetries) await sleepMs(Math.min(1000, args.intervalSeconds * 1000))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!read.ok) {
|
|
392
|
+
unreadablePolls += 1
|
|
393
|
+
lastError = read.error
|
|
394
|
+
const key = `unreadable|${read.error}`
|
|
395
|
+
if (lastReadable || key !== lastUnreadableKey) {
|
|
396
|
+
lastUnreadableKey = key
|
|
397
|
+
lastReadable = false
|
|
398
|
+
transitions += 1
|
|
399
|
+
console.error(JSON.stringify({
|
|
400
|
+
kind: 'transition',
|
|
401
|
+
progress: false,
|
|
402
|
+
reason: 'provider-unreadable',
|
|
403
|
+
at: new Date().toISOString(),
|
|
404
|
+
state: { readable: false, error: read.error },
|
|
405
|
+
}))
|
|
406
|
+
}
|
|
407
|
+
if (unreadablePolls >= args.unreadablePolls) {
|
|
408
|
+
return finish({ outcome: 'UNKNOWN', reason: 'unreadable', detail: `gh run view failed ${unreadablePolls} polls in a row: ${lastError}`, exitCode: 4 })
|
|
409
|
+
}
|
|
410
|
+
if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
|
|
411
|
+
return finish({ outcome: 'UNKNOWN', reason: 'max-minutes', detail: `watch reached its ${args.maxMinutes}m ceiling while the provider was unreadable: ${lastError}`, exitCode: 4 })
|
|
412
|
+
}
|
|
413
|
+
await sleepMs(args.intervalSeconds * 1000)
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
unreadablePolls = 0
|
|
418
|
+
const { jobs, state, key } = project(read.data)
|
|
419
|
+
latestJobs = jobs
|
|
420
|
+
lastState = state
|
|
421
|
+
lastRunUrl = read.data.url ?? null
|
|
422
|
+
|
|
423
|
+
if (!STATUSES.has(state.run.status)) {
|
|
424
|
+
return finish({ outcome: 'UNKNOWN', reason: 'unknown-run-state', detail: `unsupported run status: ${state.run.status ?? 'missing'}`, exitCode: 4 })
|
|
425
|
+
}
|
|
426
|
+
if ((state.run.conclusion && !CONCLUSIONS.has(state.run.conclusion)) ||
|
|
427
|
+
(state.run.status === 'COMPLETED' && !state.run.conclusion)) {
|
|
428
|
+
return finish({ outcome: 'UNKNOWN', reason: 'unknown-run-conclusion', detail: `unsupported run conclusion: ${state.run.conclusion ?? 'missing'}`, exitCode: 4 })
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const malformed = jobs.find((job) => !STATUSES.has(job.status) ||
|
|
432
|
+
(job.conclusion && !CONCLUSIONS.has(job.conclusion)) ||
|
|
433
|
+
job.steps.some((step) => !STATUSES.has(step.status) ||
|
|
434
|
+
(step.conclusion && !CONCLUSIONS.has(step.conclusion))))
|
|
435
|
+
if (malformed) {
|
|
436
|
+
return finish({ outcome: 'UNKNOWN', reason: 'unknown-job-state', detail: `unsupported job or step state in ${malformed.name}`, exitCode: 4 })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (key !== lastKey || !lastReadable) {
|
|
440
|
+
const stateChanged = key !== lastKey
|
|
441
|
+
const { changes, changesOmitted } = describeChanges(previousState, previousJobs, { jobs, state })
|
|
442
|
+
const reason = lastKey === null ? 'run-state-initial' : lastReadable ? 'run-state' : 'provider-readable'
|
|
443
|
+
lastKey = key
|
|
444
|
+
if (stateChanged) changedAtMs = Date.now()
|
|
445
|
+
transitions += 1
|
|
446
|
+
lastReadable = true
|
|
447
|
+
console.error(JSON.stringify({
|
|
448
|
+
kind: 'transition',
|
|
449
|
+
progress: stateChanged,
|
|
450
|
+
reason,
|
|
451
|
+
at: new Date().toISOString(),
|
|
452
|
+
...(reason === 'run-state-initial' ? {} : { changes: stateChanged ? changes : [], changesOmitted: stateChanged ? changesOmitted : 0 }),
|
|
453
|
+
state,
|
|
454
|
+
}))
|
|
455
|
+
}
|
|
456
|
+
previousState = state
|
|
457
|
+
previousJobs = jobs
|
|
458
|
+
|
|
459
|
+
for (const warning of slowRecords(jobs, { thresholdMs: slowThresholdMs, warned: warnedSlow, nowMs: Date.now(), runUrl: lastRunUrl })) {
|
|
460
|
+
console.error(warning)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (state.run.status === 'COMPLETED') {
|
|
464
|
+
const conclusion = state.run.conclusion
|
|
465
|
+
if (PASSING.has(conclusion)) {
|
|
466
|
+
return finish({
|
|
467
|
+
outcome: conclusion === 'SUCCESS' ? 'SUCCESS' : 'NEUTRAL',
|
|
468
|
+
reason: `run-${String(conclusion).toLowerCase()}`,
|
|
469
|
+
exitCode: 0,
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
const failures = failureDetail(jobs)
|
|
473
|
+
return finish({
|
|
474
|
+
outcome: conclusion === 'CANCELLED' ? 'CANCELLED' : 'FAILED',
|
|
475
|
+
reason: `run-${String(conclusion || 'unknown').toLowerCase()}`,
|
|
476
|
+
detail: failures.detail,
|
|
477
|
+
exitCode: 4,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const inProgress = state.running.length > 0 || jobs.some((job) => job.status === 'IN_PROGRESS')
|
|
482
|
+
|
|
483
|
+
if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
|
|
484
|
+
return finish({
|
|
485
|
+
outcome: 'STALLED',
|
|
486
|
+
reason: 'max-minutes',
|
|
487
|
+
detail: `watch reached its ${args.maxMinutes}m ceiling with the run still ${state.run.status ?? 'unknown'}`,
|
|
488
|
+
exitCode: 4,
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
// Nothing progressed and nothing is running: this is the shape that hides behind a
|
|
492
|
+
// repainting status line — a job queued for a runner that will never match it.
|
|
493
|
+
if (!inProgress && Date.now() - changedAtMs >= args.stallMinutes * 60_000) {
|
|
494
|
+
return finish({
|
|
495
|
+
outcome: 'STALLED',
|
|
496
|
+
reason: 'no-progress',
|
|
497
|
+
detail: state.waiting.length > 0
|
|
498
|
+
? `no job or step started for ${args.stallMinutes}m; waiting on ${state.waiting.join(', ')}`
|
|
499
|
+
: `no observable run-state change for ${args.stallMinutes}m`,
|
|
500
|
+
exitCode: 4,
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
await sleepMs(args.intervalSeconds * 1000)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
process.exitCode = await main().catch((error) => {
|
|
509
|
+
console.error(JSON.stringify({ error: 'watch-run-failed', detail: String(error?.message ?? error) }))
|
|
510
|
+
return 4
|
|
511
|
+
})
|
|
@@ -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
|
|