@1aboveio/cicd-skills 0.1.21 → 0.1.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1aboveio/cicd-skills",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "GitHub Actions and flow, Mergify, Docker and Podman, Cloud Build, Cloud Deploy and Google Cloud.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  "id": "cicd",
28
28
  "core": "@1aboveio/core-skills",
29
29
  "coreSkills": [],
30
- "digest": "24e2387878d5b0ea32ddb264a0f4bfde12e8f38eb8763b3a51fcd7c2f47f9192"
30
+ "digest": "6ba6fcb3f5bf1b6fdff64648f8c2d6537ed012c1d59430b5df3ae46fb464bd3d"
31
31
  },
32
32
  "publishConfig": {
33
33
  "registry": "https://registry.npmjs.org/",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: cloud-build
3
3
  description: "Build or debug Cloud Build pipelines for Cloud Run: cloudbuild.yaml, triggers, Artifact Registry, builds, migrations, deploys, secrets, VPC, notifications. Use cloud-deploy for staged promotion. NOT for GitHub Actions PR checks (github-actions)."
4
- release-version: 0.50.1
4
+ release-version: 0.50.3
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Cloud Build Direct Deploy to Cloud Run
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: cloud-debug
3
3
  description: "Debug Cloud Build or Deploy failures: builds, deployments, health checks, and pipeline errors. Inputs: service, build ID, region. NOT for GitHub Actions runs (github-actions) or runtime errors in a running service (app-debug)."
4
- release-version: 0.50.1
4
+ release-version: 0.50.3
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Cloud Build & Cloud Deploy Debug Skill
@@ -5,7 +5,7 @@ description: >
5
5
  CI/CD pipelines with staged promotion, verification gates, or GitFlow branching.
6
6
  Also use when debugging Cloud Deploy pipeline failures. NOT for simple single-container
7
7
  deploys — use cloud-build instead.
8
- release-version: 0.50.1
8
+ release-version: 0.50.3
9
9
  ---
10
10
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
11
11
  # Cloud Deploy + Skaffold + Kustomize for Cloud Run
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: docker
3
3
  description: Docker/Podman container architecture and build patterns. Read when designing or reviewing containerization, Dockerfile structure, multi-stage builds, PyPI mirror support, or docker-compose setup for Python/uv projects, and when a Dockerfile's dependency install misses the layer cache on every build (build args such as a commit SHA or version declared too early) or an image build is slow in CI.
4
- release-version: 0.50.1
4
+ release-version: 0.50.3
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Docker Build Patterns
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: github-actions
3
3
  description: "Author, review, audit, profile, optimize, or debug GitHub Actions workflows (.github/workflows/*.yml): run-history audits, job and step timing, resource bottlenecks, before/after comparisons, conditional CI by changed path, the one required gate check, PR and merge-queue lanes, sharding, workers, caching, self-hosted runners, timeouts, concurrency, permissions and action pinning, post-deploy verification, npm publishing. Use for 'set up CI', 'CI is slow', 'profile CI', 'optimize our pipeline', 'audit CI for redundant or wasted jobs', 'why is this check stuck expected or never started', 'harden this workflow', or 'browser not found on the runner'. NOT for pushing, opening, or merging a PR, or writing CI conventions into CONTRIBUTING.md (github-flow), queue rules or a check unmet inside Mergify (mergify), Playwright config and specs (e2e-test), cloudbuild.yaml or Cloud Run deploys (cloud-build), or which gates a repo must prove (ensure-coverage)."
4
- release-version: 0.50.1
4
+ release-version: 0.50.3
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # GitHub Actions
@@ -43,7 +43,9 @@ replacement of an established pipeline during an audit or performance-only chang
43
43
  7. **Add post-deploy and scheduled lanes** if the repo deploys or publishes.
44
44
  → [post-deploy-and-publishing.md](references/post-deploy-and-publishing.md)
45
45
  8. **Verify**: `actionlint`; one PR per area runs only that area's jobs; timings meet the SLA.
46
- Failures → [debugging.md](references/debugging.md)
46
+ Failures → [debugging.md](references/debugging.md). Waiting on a live run, or on a publish
47
+ run → [debugging.md#watching-a-run](references/debugging.md#watching-a-run): `scripts/watch-run.mjs`
48
+ reports job/step transitions and slow steps, which `gh run watch`'s 30-second repaint hides.
47
49
 
48
50
  ## Principles
49
51
 
@@ -11,3 +11,24 @@
11
11
  - Queue/dequeue questions → `mergify` skill; `gh pr checks` can't see a dequeue.
12
12
  - Validate locally: `actionlint` for syntax and expression mistakes; `act` for simple jobs
13
13
  (not for self-hosted or service-container jobs).
14
+
15
+ ## Watching a run
16
+
17
+ `gh run watch` repaints a status block and an elapsed clock every 30 seconds, and that repaint
18
+ is not progress: a job wedged in one step reads exactly like a job moving, while the step names
19
+ are what scrolls out of view. Watch with the bounded CLI instead:
20
+
21
+ ```bash
22
+ node "$SKILL_DIR/scripts/watch-run.mjs" --run <id> [--repo owner/repo]
23
+ ```
24
+
25
+ It reads `gh run view --json status,conclusion,jobs` on an interval and writes one record only
26
+ when the run's state changes, naming the job/step transitions that caused it (`changes`). A job
27
+ or step past `--slow-step-minutes` is warned about once **while it is still running**, with its
28
+ duration and URL. The exit code is the verdict: 0 green; 4 names the failed job and its failed
29
+ steps, or `STALLED` with `reason: no-progress` naming the jobs nothing has started, or
30
+ `UNKNOWN` when the provider stays unreadable — an unreadable read is no information, never a
31
+ red run.
32
+
33
+ For a finished run's full job/step profile, that is the other tool:
34
+ `scripts/ci-run-audit.mjs --profile-run <id>`.
@@ -31,3 +31,10 @@ npm packages: trusted publishing (OIDC, no token): `environment: npm-publish`,
31
31
  already published? then assert its provenance matches this commit), a pack-contents check,
32
32
  publish, poll the registry until visible, then create the tag and release idempotently.
33
33
  Restrict to the default branch; pin the npm version; keep `contents: write` off the install step.
34
+
35
+ **The registry is the verdict, not the run.** A green publish workflow proves the job exited 0;
36
+ the published version is what the registry reports. Wait on the artifact (`npm view
37
+ <pkg>@<version> version`), and read the run for diagnosis with `scripts/watch-run.mjs` — its
38
+ job/step transitions and slow-step warnings are the useful signal, while a repainted status
39
+ line is not. A run that fails after the version appears is a hand-back naming what already
40
+ shipped; a version that never appears is not a success however green the run was.
@@ -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
+ })