@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 +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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1aboveio/cicd-skills",
|
|
3
|
-
"version": "0.1.
|
|
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": "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/skills/docker/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
})
|