@miroshnik/elves 0.0.0 → 2026.1018.0
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/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +203 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +221 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4854 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +231 -0
- package/skills/task-env/proxy/compose.yaml +51 -0
- package/skills/task-env/reference.md +254 -0
- package/skills/task-env/scripts/task-env.ts +1017 -0
package/package.json
CHANGED
|
@@ -1 +1,70 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@miroshnik/elves",
|
|
3
|
+
"version": "2026.1018.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Elves — a development flow with AI agents: shared rules and skills for any agent; install — bunx @miroshnik/elves install -g; the installer and skill scripts — TypeScript under Bun, tests — bun test in tests/",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/miroshnik/elves.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"elves": "bin/elves.ts"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
"./spec/harness": {
|
|
15
|
+
"types": "./skills/spec/scripts/harness.ts",
|
|
16
|
+
"bun": "./skills/spec/scripts/harness.ts",
|
|
17
|
+
"default": "./dist/spec/scripts/harness.js"
|
|
18
|
+
},
|
|
19
|
+
"./spec/architecture": {
|
|
20
|
+
"types": "./skills/spec/scripts/architecture.ts",
|
|
21
|
+
"bun": "./skills/spec/scripts/architecture.ts",
|
|
22
|
+
"default": "./dist/spec/scripts/architecture.js"
|
|
23
|
+
},
|
|
24
|
+
"./spec/eslint-config": {
|
|
25
|
+
"types": "./skills/spec/scripts/eslint-config.ts",
|
|
26
|
+
"bun": "./skills/spec/scripts/eslint-config.ts",
|
|
27
|
+
"default": "./dist/spec/scripts/eslint-config.js"
|
|
28
|
+
},
|
|
29
|
+
"./github": {
|
|
30
|
+
"types": "./skills/github/scripts/github.ts",
|
|
31
|
+
"bun": "./skills/github/scripts/github.ts",
|
|
32
|
+
"default": "./dist/github/scripts/github.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin",
|
|
38
|
+
"dist",
|
|
39
|
+
"AGENTS.md",
|
|
40
|
+
"claude",
|
|
41
|
+
"docs/*.md",
|
|
42
|
+
"skills"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"provenance": true
|
|
47
|
+
},
|
|
48
|
+
"packageManager": "bun@1.4.3",
|
|
49
|
+
"engines": {
|
|
50
|
+
"bun": ">=1.2"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "bun --no-env-file skills/slot/scripts/slot.ts 'bun test --path-ignore-patterns=\"**/*.slow.test.ts\" --path-ignore-patterns=\"tests/standards/**\"'",
|
|
54
|
+
"test:slow": "bun --no-env-file skills/slot/scripts/slot.ts 'bun test .slow.test.ts'",
|
|
55
|
+
"test:spec": "bun --no-env-file skills/slot/scripts/slot.ts 'bun test tests/standards'",
|
|
56
|
+
"typecheck": "bun --no-env-file skills/slot/scripts/slot.ts 'bun check'",
|
|
57
|
+
"spec:doc": "bun test --reporter=junit --reporter-outfile=.spec-report.xml && bun --no-env-file skills/spec/scripts/spec-doc.ts .spec-report.xml --strict",
|
|
58
|
+
"spec:diff": "bun --no-env-file skills/spec/scripts/spec-diff.ts",
|
|
59
|
+
"spec:publish": "bun --no-env-file skills/spec/scripts/spec-publish.ts"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@playwright/test": "^1.63.0",
|
|
63
|
+
"@types/bun": "^1.4.2",
|
|
64
|
+
"@types/node": "^26.6.2",
|
|
65
|
+
"eslint": "^10.11.0",
|
|
66
|
+
"eslint-plugin-boundaries": "^7.2.0",
|
|
67
|
+
"knip": "^6.41.0"
|
|
68
|
+
},
|
|
69
|
+
"gitHead": "e6b283ff871be91cc91f33e2a5637893d1f5e538"
|
|
70
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ci-wait
|
|
3
|
+
description: Waiting for all checks of a PR or commit (CI in GitHub Actions, the host's deploy, an external check) with a script that shows progress and has the outcomes PASS / FAIL / TIMEOUT / ERROR. When — after a push, before merging a PR; after a merge, before reporting "done", if the merge triggers a deploy (`merged <N>` — the script takes the merge SHA itself); whenever you need to wait for CI or a deploy, instead of `gh pr checks --watch`, `sleep` in a loop and manual polling; `issue <N>` — for the closing of the bug on a red main (`github pr premerge`, exit code 3).
|
|
4
|
+
allowed-tools: Bash(bash *skills/ci-wait/scripts/wait-ci.sh *) Bash(gh pr checks *) Bash(gh pr view *) Bash(git rev-parse *)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# ci-wait — wait for checks, don't guess
|
|
8
|
+
|
|
9
|
+
Waiting for CI and deploys must be observable, time-bounded and complete in
|
|
10
|
+
its outcomes. `gh pr checks --watch` right after a push confuses "checks not
|
|
11
|
+
registered yet" with "passed", `sleep` in a loop doesn't show what is
|
|
12
|
+
happening, and manual polling gets forgotten. The `scripts/wait-ci.sh` script
|
|
13
|
+
next to this file does it all deterministically; the agent only reads the
|
|
14
|
+
outcome.
|
|
15
|
+
|
|
16
|
+
Run it from the repository directory or with `--repo owner/repo` in any mode
|
|
17
|
+
(as in the `github` scripts; or `GH_REPO=owner/repo`); in the
|
|
18
|
+
background — from a directory that will outlive the wait (the main checkout,
|
|
19
|
+
not a worktree that will be removed earlier): a directory removed meanwhile
|
|
20
|
+
gives `ERROR getcwd`. As a background command — **without `| tail` or other
|
|
21
|
+
filters**: the state lines land in the output file as the checks change, a
|
|
22
|
+
filter holds them back until the end. A background wait dies with the app's
|
|
23
|
+
process: a session resumed after a restart gets the command to restart it from
|
|
24
|
+
the `SessionStart` hook (`github task resume`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bash <skill dir>/scripts/wait-ci.sh pr <N> [--interval 30] [--timeout 1800] [--expect 0] [--wait-all]
|
|
28
|
+
bash <skill dir>/scripts/wait-ci.sh status <sha> [--context <name>] [--interval 20] [--timeout 1200] [--expect 0] [--wait-all]
|
|
29
|
+
bash <skill dir>/scripts/wait-ci.sh merged <N> [--context <name>] [--interval 20] [--timeout 1200] [--expect 0] [--wait-all]
|
|
30
|
+
bash <skill dir>/scripts/wait-ci.sh issue <N> [--interval 60] [--timeout 7200]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What to run when
|
|
34
|
+
|
|
35
|
+
| Moment | Command |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Pushed the PR branch, before merging | `pr <N>` — waits for the PR head's checks, the base branch's required checks and a run of every workflow whose `on:` fires on the PR; a conflict with the base — ERROR on the second poll of the same head, without waiting for the checks |
|
|
38
|
+
| Merged, and the merge triggers a deploy or CI on `main` | `merged <N>` as a background command in the same turn as `github task actualize` — waits for all checks of the PR's merge commit: GitHub Actions check-runs and the statuses of the host and external services; takes the SHA from the PR itself, an unmerged PR — ERROR |
|
|
39
|
+
| A commit's checks by SHA (my own commit without a PR) | `status <sha>` — the same as `merged`, by the full SHA |
|
|
40
|
+
| One check of a commit is needed (the deploy among others) | `merged <N> --context <name>` or `status <sha> --context <name>` |
|
|
41
|
+
| `github pr premerge` — exit code 3, `main` is red | `issue <N>` as a background command — waits for the bug on the red `main` to close: PASS when it is closed (with the reason: `completed`, `not_planned`); no such issue — ERROR; then `premerge` again |
|
|
42
|
+
|
|
43
|
+
Don't fetch the SHA of your own merge — `merged <N>` takes it from the PR
|
|
44
|
+
itself: separate `gh pr view` turns for it were the main source of extra turns
|
|
45
|
+
after a merge (#285), and `git rev-parse origin/main` with parallel merges
|
|
46
|
+
returns someone else's later commit. The `<sha>` for `status` is the full one;
|
|
47
|
+
don't fill in a short one: for a nonexistent SHA the API silently returns an
|
|
48
|
+
empty status, and the wait hangs until the timeout with a false "not ready".
|
|
49
|
+
|
|
50
|
+
The name for `--context` is a status context or a check-run name (an Actions
|
|
51
|
+
job); the state lines of a run without `--context` name the awaited ones, all
|
|
52
|
+
of them — `gh pr checks <N>`.
|
|
53
|
+
|
|
54
|
+
## How to read the result
|
|
55
|
+
|
|
56
|
+
**The result is the exit code: don't read the output file** — neither while
|
|
57
|
+
waiting (the command's completion wakes the session) nor after code `0`
|
|
58
|
+
(PASS); only a non-zero code sends you to its `RESULT:` line.
|
|
59
|
+
|
|
60
|
+
- stdout — a state line only when the state changes: the time, how long the
|
|
61
|
+
wait has run, how many checks are done and what is awaited —
|
|
62
|
+
`12:04:31 +3m10s pr#42@1a2b3c4: 3/5 done; waiting: tests, e2e; missing
|
|
63
|
+
required: lint` (`no run of workflow: <path>`, `failed: …` with
|
|
64
|
+
`--wait-all`, `API rate limit — next poll in …`); the final line `RESULT:
|
|
65
|
+
PASS|FAIL|TIMEOUT|ERROR …`.
|
|
66
|
+
- stderr — `gh` errors and notes (the base branch's required checks, the
|
|
67
|
+
workflows due on the PR).
|
|
68
|
+
- Exit code: `0` PASS · `1` FAIL (the failed ones are listed) · `2` TIMEOUT ·
|
|
69
|
+
`3` ERROR (a conflict with the base, PR or SHA not found, five `gh` errors
|
|
70
|
+
in a row, a wrong invocation).
|
|
71
|
+
|
|
72
|
+
What to do next:
|
|
73
|
+
|
|
74
|
+
- **PASS** — before merging a PR, `github pr premerge <N>` (if `main` moved
|
|
75
|
+
after CI — a check of the merge with a fresh one), exit code `0` — merge;
|
|
76
|
+
after the merge — `github task close` (the epic refresh ran in parallel with
|
|
77
|
+
the wait — the `github` skill, `task actualize`) and the "done" report.
|
|
78
|
+
- **PASS only on the host's checks, without Actions** (the state lines named
|
|
79
|
+
only preview and comment statuses) in a repository with CI in Actions —
|
|
80
|
+
suspicious: `gh run list --commit <sha>` — was there a run and how did it
|
|
81
|
+
end.
|
|
82
|
+
- **FAIL `workflow not parsed: <path> <link>`** — the workflow file on this
|
|
83
|
+
SHA doesn't parse (YAML), a run without a single job; `workflow failed
|
|
84
|
+
without jobs` — it didn't start (a reference to a nonexistent reusable
|
|
85
|
+
workflow, a policy ban). Fix the file; the reason is on the run's page at
|
|
86
|
+
the link.
|
|
87
|
+
- **FAIL** — came on the first failed check, the others may still have been
|
|
88
|
+
running (a full snapshot — `--wait-all`). Read the failed check's logs
|
|
89
|
+
(`gh run view <id> --log-failed`), fix the cause; a "known red first
|
|
90
|
+
build" — re-run your own run (`gh run rerun <id> --failed`) and `ci-wait`
|
|
91
|
+
again. Don't merge. Jobs didn't start, with the annotation "recent account
|
|
92
|
+
payments have failed…" — the owner's billing, not the code
|
|
93
|
+
(`docs/pr-checks.md`).
|
|
94
|
+
- **TIMEOUT** — count it neither as a success nor as a failure; the result
|
|
95
|
+
names what was still awaited: `no checks yet` — none registered; `waiting:
|
|
96
|
+
…` — a stuck runner or a long job; `waiting: workflow queued: <path>` — the
|
|
97
|
+
runner never picked up the run; `missing required` — a required check never
|
|
98
|
+
appeared (the workflow didn't start or was renamed); `no run of workflow:
|
|
99
|
+
<path>` — by its `on:` the workflow should have fired on the PR, but there
|
|
100
|
+
is no run on the head (Actions disabled, a GitHub incident, a PR from a fork
|
|
101
|
+
with a file that doesn't parse). If needed, repeat with a larger
|
|
102
|
+
`--timeout`.
|
|
103
|
+
- **ERROR `conflict with base`** — nothing to wait for: rebase onto
|
|
104
|
+
`origin/<base>`, `git push --force-with-lease`, `ci-wait` again. Any other
|
|
105
|
+
ERROR is an access or invocation problem: the repository, `gh` permissions,
|
|
106
|
+
the PR number, the SHA.
|
|
107
|
+
|
|
108
|
+
## Details the script exists for
|
|
109
|
+
|
|
110
|
+
- An empty check list is pending, not "passed": checks register with a delay
|
|
111
|
+
after a push.
|
|
112
|
+
- A commit has checks in two places: statuses (`commits/<sha>/status`) are
|
|
113
|
+
written by hosts and external services, check-runs
|
|
114
|
+
(`commits/<sha>/check-runs`) by GitHub Actions. Waiting for statuses alone
|
|
115
|
+
on a commit whose whole CI is Actions hangs until the timeout; `status`
|
|
116
|
+
waits for both. `pr` waits the same way, by the PR head's SHA.
|
|
117
|
+
- One SHA holds the check-runs of all runs: one cancelled by a new push stays
|
|
118
|
+
when the head is moved back to the earlier commit, and re-running a job
|
|
119
|
+
adds a new one. The result follows the latest check-run of a name from the
|
|
120
|
+
same app, otherwise an early `cancelled` gives a false FAIL while the latest
|
|
121
|
+
one is green; a name from several apps gets the worst of their states.
|
|
122
|
+
`github task show` reads a check by the same table (standard
|
|
123
|
+
`check-run-state`).
|
|
124
|
+
- REST only: `gh pr checks` goes to GraphQL, whose quota (5000 points per
|
|
125
|
+
hour) is shared by all of the user's sessions — with parallel sessions it
|
|
126
|
+
runs out. The PR head (`pulls/<N>`) is read on every poll: a new push
|
|
127
|
+
changes the SHA. A REST rate limit means waiting until the quota resets
|
|
128
|
+
(`gh api rate_limit`), not ERROR; that wait doesn't count toward
|
|
129
|
+
`--timeout` (up to an hour).
|
|
130
|
+
- The poll interval grows with the wait: `--interval` is the first one, then a
|
|
131
|
+
tenth of the elapsed time, at most 5 minutes — a long deploy is polled
|
|
132
|
+
several times less often, the quota lasts, and the outcome comes at most a
|
|
133
|
+
tenth of the wait late. The confirming poll before PASS — at the first
|
|
134
|
+
interval.
|
|
135
|
+
- A `gh` failure isn't an outcome, in the requests before the first poll too
|
|
136
|
+
(the commit for `status`, the required checks for `pr`): a retry, ERROR
|
|
137
|
+
after five in a row. "Commit not found" is only the API's answer "no" (422,
|
|
138
|
+
404); otherwise a one-off failure during a GitHub incident ended the wait
|
|
139
|
+
with a false "not found".
|
|
140
|
+
- A PR with a conflict with the base (`mergeable: false`): there is no
|
|
141
|
+
merge-ref, and workflows on `pull_request` don't start at all — without
|
|
142
|
+
this check the wait ended with a result from the host's statuses alone.
|
|
143
|
+
`mergeable: null` — GitHub is still computing; keep waiting. Right after a
|
|
144
|
+
push GitHub still returns the previous head's `false` — the conflict that
|
|
145
|
+
was the reason for the rebase: a conflict counts only when the next poll of
|
|
146
|
+
the same head repeats it; until then — `waiting: conflict with base <base>:
|
|
147
|
+
rechecking`.
|
|
148
|
+
- The base branch's required checks (ruleset and classic protection) are
|
|
149
|
+
awaited by name: while any one is missing there is no final result, even if
|
|
150
|
+
the rest have finished.
|
|
151
|
+
- An Actions run that failed without a single job gives no check-run: when
|
|
152
|
+
the workflow isn't parsed, GitHub leaves a "dummy" run (the name is the
|
|
153
|
+
file path, event `push`, `conclusion: failure`), and the result came down
|
|
154
|
+
to the host's statuses alone — PASS without a single test run. So every
|
|
155
|
+
poll also reads the Actions runs on the SHA (`actions/runs?head_sha=`).
|
|
156
|
+
- A queued run hasn't produced check-runs yet — without it the result also
|
|
157
|
+
came down to the host's statuses (#360). An unfinished run without
|
|
158
|
+
check-runs is pending `workflow queued: <path>` (or `in_progress`) until
|
|
159
|
+
the checks of its jobs appear.
|
|
160
|
+
- `pr` waits for a run of every active workflow that, by its `on:` on the
|
|
161
|
+
head SHA, fires on the PR (`bun` parses the YAML; without it the check is
|
|
162
|
+
skipped with a note in stderr): `pull_request` without filters or with
|
|
163
|
+
branches that let the base through. A paths filter, `types` without
|
|
164
|
+
`synchronize`, a negation in branches can't be decided without the diff —
|
|
165
|
+
such a workflow isn't awaited; a file that doesn't parse is awaited: a PR
|
|
166
|
+
from a fork doesn't even get the "dummy".
|
|
167
|
+
- The spec publish job (`spec-publish`, also `<job> / spec-publish` from a
|
|
168
|
+
reusable workflow — the `spec` skill, `ci.md`) isn't a check: it publishes
|
|
169
|
+
the documentation, so it is neither awaited nor a FAIL — after a merge the
|
|
170
|
+
deploy and `main`'s checks are awaited without it (#443). `--context
|
|
171
|
+
spec-publish` waits for it like any check.
|
|
172
|
+
- A failed check means the result is already known: a long check next to it
|
|
173
|
+
(a preview deploy) won't change it, so FAIL comes at once.
|
|
174
|
+
- PASS counts only when a snapshot without pending repeats for two polls in
|
|
175
|
+
a row; `--expect N` — the minimum number of checks, below which we keep
|
|
176
|
+
waiting.
|
|
177
|
+
- Without branch protection `gh pr merge --auto` merges without waiting for
|
|
178
|
+
CI — so `--auto` doesn't replace waiting.
|
|
179
|
+
- One wait at a time: don't start a second `ci-wait` on the same PR in
|
|
180
|
+
parallel.
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# wait-ci.sh — waiting for the checks of a PR or a commit with visible progress and the full set
|
|
4
|
+
# of outcomes. Run from the repository directory (gh resolves {owner}/{repo}), with --repo owner/repo or GH_REPO.
|
|
5
|
+
#
|
|
6
|
+
# wait-ci.sh pr <N> | status <sha> | merged <N> | issue <N> [options] — the modes and options: wait-ci.sh --help,
|
|
7
|
+
# a mode's — wait-ci.sh <mode> --help (the help() function below).
|
|
8
|
+
#
|
|
9
|
+
# An Actions run at the SHA that failed without a single job (workflow not parsed, not started) — FAIL with the file path:
|
|
10
|
+
# it gives no check-run, and without it the outcome came down to hosting statuses alone. An unfinished run without
|
|
11
|
+
# check-runs (queued) — pending: there is a run at the SHA, but it has no checks yet.
|
|
12
|
+
# The spec publish job (spec-publish) is not a check: neither awaited nor a FAIL — only by --context.
|
|
13
|
+
# An empty list of checks — pending; PASS counts when a snapshot without pending repeats
|
|
14
|
+
# two polls in a row (late-registering checks don't slip through) and there are no fewer checks
|
|
15
|
+
# than --expect. The first failed check — FAIL right away; --wait-all — FAIL when all have finished.
|
|
16
|
+
# --interval is the first interval: it grows with the wait — a tenth of the elapsed time, up to 5 min.
|
|
17
|
+
# REST only: the GraphQL quota is shared by all sessions; a REST rate limit — waiting until the reset, and that wait
|
|
18
|
+
# doesn't count toward --timeout (up to an hour). A gh failure (also in requests before the first poll) — retry, ERROR
|
|
19
|
+
# after five in a row; "not found" — only on 404/422.
|
|
20
|
+
#
|
|
21
|
+
# Output: stdout — a state line only when the state changes ("<time> +<elapsed> <what>: 3/5 done; waiting: …")
|
|
22
|
+
# and the final line "RESULT: PASS|FAIL|TIMEOUT|ERROR …"; stderr — gh errors and notes.
|
|
23
|
+
# Exit: 0 PASS · 1 FAIL · 2 TIMEOUT · 3 ERROR or a wrong call. The outcome is the exit code.
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
# help [mode]: the mode's synopsis, what it does and examples, otherwise the overview (tests/standards/cli-help);
|
|
27
|
+
# printf is a builtin — help runs no commands. The overview takes a mode's first line by command substitution, not a
|
|
28
|
+
# pipeline (#495): `help "$m" | { read -r line; … }` — the reader leaves after one line, bash writes stdout line by line,
|
|
29
|
+
# and the writer got SIGPIPE under load; with pipefail the last mode's 141 broke the && chain, and the tail was lost
|
|
30
|
+
help() {
|
|
31
|
+
local m synopsis exits=("Exit: 0 PASS · 1 FAIL · 2 TIMEOUT · 3 ERROR or a wrong call — the outcome is the exit code; stdout — a state" \
|
|
32
|
+
"line when the state changes and the final line RESULT: PASS|FAIL|TIMEOUT|ERROR …, stderr — gh errors and notes.")
|
|
33
|
+
case "${1:-}" in
|
|
34
|
+
pr) printf '%s\n' \
|
|
35
|
+
"wait-ci pr <N> [--repo owner/repo] [--interval 30] [--timeout 1800] [--expect 0] [--wait-all]" "" \
|
|
36
|
+
"Waits for the checks of the PR head (like status), the base branch's required checks — by name, and the run of each" \
|
|
37
|
+
"workflow that, by its on: at the head SHA, fires on the PR (needs bun). A conflict with the base — ERROR once the next" \
|
|
38
|
+
"poll of the same head confirms it: CI won't start on such a PR; right after a push GitHub still reports the previous" \
|
|
39
|
+
"head's conflict. The first failed check — FAIL right away; --wait-all — FAIL when all have finished." \
|
|
40
|
+
"${exits[@]}" "" "Examples:" " wait-ci pr 57" " wait-ci pr 57 --repo octo/app --timeout 3600 --wait-all" ;;
|
|
41
|
+
status) printf '%s\n' \
|
|
42
|
+
"wait-ci status <sha> [--repo owner/repo] [--context <name>] [--interval 20] [--timeout 1200] [--expect 0] [--wait-all]" "" \
|
|
43
|
+
"Waits for all checks of a commit: commit statuses (hosting, external services) and check-runs (GitHub Actions)." \
|
|
44
|
+
"--context — one check by name, among both. The SHA is the full one, from git." \
|
|
45
|
+
"${exits[@]}" "" "Examples:" " wait-ci status \"\$(git rev-parse HEAD)\"" " wait-ci status \"\$(git rev-parse HEAD)\" --context Vercel --timeout 600" ;;
|
|
46
|
+
merged) printf '%s\n' \
|
|
47
|
+
"wait-ci merged <N> [--repo owner/repo] [--context <name>] [--interval 20] [--timeout 1200] [--expect 0] [--wait-all]" "" \
|
|
48
|
+
"Waits for all checks of the PR's merge commit (like status): takes the SHA from the PR itself — don't look it up" \
|
|
49
|
+
"after the merge. PR not merged — ERROR." \
|
|
50
|
+
"${exits[@]}" "" "Examples:" " wait-ci merged 57" " wait-ci merged 57 --context Vercel" ;;
|
|
51
|
+
issue) printf '%s\n' \
|
|
52
|
+
"wait-ci issue <N> [--repo owner/repo] [--interval 60] [--timeout 7200]" "" \
|
|
53
|
+
"Waits for an issue to close — the bug on a red main (github pr premerge, code 3): PASS when it is closed; no such" \
|
|
54
|
+
"issue — ERROR." \
|
|
55
|
+
"${exits[@]}" "" "Examples:" " wait-ci issue 61" ;;
|
|
56
|
+
*) printf '%s\n' \
|
|
57
|
+
"wait-ci — waiting for the checks of a PR or a commit with visible progress and the full set of outcomes; the" \
|
|
58
|
+
"repository — --repo owner/repo (as in the github scripts), otherwise GH_REPO or the current directory's." ""
|
|
59
|
+
for m in pr status merged issue; do synopsis=$(help "$m"); printf ' %s\n' "${synopsis%%$'\n'*}"; done
|
|
60
|
+
printf '%s\n' "" "wait-ci <command> --help — the command's details and examples" ;;
|
|
61
|
+
esac
|
|
62
|
+
}
|
|
63
|
+
usage() { help "${mode:-}" >&2; exit 3; }
|
|
64
|
+
|
|
65
|
+
for arg in "$@"; do
|
|
66
|
+
case "$arg" in --help|-h) help "${1:-}"; exit 0 ;; esac
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
mode=${1:-}; target=${2:-}
|
|
70
|
+
[ -n "$mode" ] && [ -n "$target" ] || usage
|
|
71
|
+
shift 2
|
|
72
|
+
interval=""; timeout=""; expect=0; context=""; wait_all=""
|
|
73
|
+
while [ $# -gt 0 ]; do
|
|
74
|
+
case "$1" in
|
|
75
|
+
--interval) interval=$2; shift 2 ;;
|
|
76
|
+
--timeout) timeout=$2; shift 2 ;;
|
|
77
|
+
--expect) expect=$2; shift 2 ;;
|
|
78
|
+
--context) context=$2; shift 2 ;;
|
|
79
|
+
--wait-all) wait_all=1; shift ;;
|
|
80
|
+
# the repository for gh, as --repo of the github scripts (#514): gh api resolves {owner}/{repo} from GH_REPO
|
|
81
|
+
--repo) [[ "${2:-}" == [!-]*/?* ]] || { echo "--repo expects owner/repo, got: ${2:-}" >&2; usage; }
|
|
82
|
+
export GH_REPO=$2; shift 2 ;;
|
|
83
|
+
*) echo "unknown arg: $1" >&2; usage ;;
|
|
84
|
+
esac
|
|
85
|
+
done
|
|
86
|
+
ts() { date +%H:%M:%S; }
|
|
87
|
+
finish() { echo "RESULT: $1"; exit "$2"; }
|
|
88
|
+
rate_limited() { grep -qi 'rate limit' <<<"$1"; }
|
|
89
|
+
|
|
90
|
+
# checks <sha>: the commit's checks into $out — a JSON array {name, bucket}; bucket as in `gh pr checks`:
|
|
91
|
+
# pending | pass | fail | cancel | skipping; paths of the workflows that produced a run at the SHA — into $ran. Code 1 — a gh
|
|
92
|
+
# error, the text in $out.
|
|
93
|
+
# The API already reduces statuses to the latest per context, but not check-runs: the SHA holds check-runs of all
|
|
94
|
+
# runs (one cancelled by a new push when the head went back to an earlier commit; a job rerun) — CHECK_STATES below
|
|
95
|
+
# reads them. An Actions run that failed without jobs gives no check-run: the workflow wasn't parsed (the run's name is
|
|
96
|
+
# the file path) or didn't start (its check suite has no check-runs); such a run is a failed check with the file path
|
|
97
|
+
# and a link. An unfinished run without check-runs (queued) — pending with the path: there is a run at the SHA, and
|
|
98
|
+
# without this line the outcome came down to hosting statuses (#360).
|
|
99
|
+
# The spec publish job (the spec skill, ci.md; "<job> / spec-publish" — the same job in a reusable workflow) — publishing
|
|
100
|
+
# the documentation, not a check: it is neither awaited nor a FAIL, only by --context (#443). A copy — in the github
|
|
101
|
+
# skill's premerge (SPEC_PUBLISH): its red isn't a red main; both answer by one table, tests/standards/spec-publish-job.
|
|
102
|
+
SPEC_PUBLISH='^(.+ / )?spec-publish$'
|
|
103
|
+
# The state of each check-run name (jq check_states over REST check-runs): not completed — pending; success — pass;
|
|
104
|
+
# neutral, skipped — skipping; cancelled — cancel; any other conclusion — fail. Reruns of one name — the last check-run
|
|
105
|
+
# of each app (the id grows with creation), not the best one and not the order of the response; several apps — the
|
|
106
|
+
# worst of their states. A copy — in the github skill (checkStates: task show, project check): both answer by one table,
|
|
107
|
+
# tests/standards/check-run-state.
|
|
108
|
+
CHECK_STATES='def check_states:
|
|
109
|
+
group_by([.name, .app.id]) | map(max_by(.id))
|
|
110
|
+
| map({ name, bucket: (if .status != "completed" then "pending"
|
|
111
|
+
elif .conclusion == "success" then "pass"
|
|
112
|
+
elif .conclusion == "neutral" or .conclusion == "skipped" then "skipping"
|
|
113
|
+
elif .conclusion == "cancelled" then "cancel"
|
|
114
|
+
else "fail" end) })
|
|
115
|
+
| group_by(.name) | map(min_by(.bucket as $b | ["fail", "cancel", "pending", "skipping", "pass"] | index($b)));'
|
|
116
|
+
ran='[]'
|
|
117
|
+
checks() {
|
|
118
|
+
local statuses runs wruns
|
|
119
|
+
statuses=$(gh api "repos/{owner}/{repo}/commits/$1/status" 2>&1) || { out=$statuses; return 1; }
|
|
120
|
+
runs=$(gh api "repos/{owner}/{repo}/commits/$1/check-runs?per_page=100" 2>&1) || { out=$runs; return 1; }
|
|
121
|
+
wruns=$(gh api "repos/{owner}/{repo}/actions/runs?head_sha=$1&per_page=100" 2>&1) || { out=$wruns; return 1; }
|
|
122
|
+
ran=$(jq -c '[(.workflow_runs // [])[].path] | unique' <<<"$wruns")
|
|
123
|
+
out=$(jq -n --argjson s "$statuses" --argjson r "$runs" --argjson w "$wruns" --arg ctx "$context" --arg publish "$SPEC_PUBLISH" "$CHECK_STATES"'
|
|
124
|
+
[ $s.statuses[] | { name: .context,
|
|
125
|
+
bucket: (if .state == "success" then "pass" elif .state == "pending" then "pending" else "fail" end) } ]
|
|
126
|
+
+ ($r.check_runs | check_states)
|
|
127
|
+
+ [ [$r.check_runs[].check_suite.id] as $suites
|
|
128
|
+
| ($w.workflow_runs // []) | group_by([.path, .event]) | map(max_by(.id)) | .[]
|
|
129
|
+
| (.check_suite_id as $id | any($suites[]; . == $id) | not) as $nojobs
|
|
130
|
+
| if .status != "completed" then
|
|
131
|
+
select($nojobs) | { name: "workflow \(.status): \(.path) \(.html_url)", bucket: "pending" }
|
|
132
|
+
else
|
|
133
|
+
select((.conclusion == "failure" or .conclusion == "startup_failure") and (.name == .path or $nojobs))
|
|
134
|
+
| { name: "workflow \(if .name == .path then "not parsed" else "failed without jobs" end): \(.path) \(.html_url)",
|
|
135
|
+
bucket: "fail" }
|
|
136
|
+
end ]
|
|
137
|
+
| if $ctx != "" then map(select(.name == $ctx)) else map(select(.name | test($publish) | not)) end' 2>&1)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# required: names of the base branch's required checks — a ruleset and classic protection. No rules
|
|
141
|
+
# or no right to read them (a private repo on Free answers 403, no branch — 404) — no required checks;
|
|
142
|
+
# a rate limit or another failure — code 1, like a poll error: a one-off failure must not erase the required checks.
|
|
143
|
+
required='[]'; required_loaded=""
|
|
144
|
+
absent() { [[ "$1" == *"HTTP 403"* || "$1" == *"HTTP 404"* ]] && ! rate_limited "$1"; }
|
|
145
|
+
load_required() {
|
|
146
|
+
local rules branch
|
|
147
|
+
rules=$(gh api "repos/{owner}/{repo}/rules/branches/$1" 2>&1) || { absent "$rules" || { out=$rules; return 1; }; rules='[]'; }
|
|
148
|
+
branch=$(gh api "repos/{owner}/{repo}/branches/$1" 2>&1) || { absent "$branch" || { out=$branch; return 1; }; branch='{}'; }
|
|
149
|
+
required=$(jq -nc --argjson r "$rules" --argjson b "$branch" '
|
|
150
|
+
[ ($r | arrays | .[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context),
|
|
151
|
+
($b | objects | .protection.required_status_checks // {} | select(.enforcement_level != "off") | (.contexts // [])[]) ]
|
|
152
|
+
| unique' 2>/dev/null) || required='[]'
|
|
153
|
+
required_loaded=1
|
|
154
|
+
[ "$required" != '[]' ] && echo "$(ts) required on $1: $(jq -r 'join(", ")' <<<"$required")" >&2
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# expected: paths of the workflows that should produce a run at the head on a PR event — by the `on:` of their files at
|
|
159
|
+
# its SHA (#356). We wait for `pull_request` without filters or with branches that let the base in; a path filter, `types`
|
|
160
|
+
# without `synchronize`, negation and other special characters in branches can't be decided without the PR diff — we
|
|
161
|
+
# don't wait: a false TIMEOUT is worse. A file that doesn't parse — we wait: a "dummy" run will give FAIL, and a PR from a
|
|
162
|
+
# fork has none at all. Active workflows only.
|
|
163
|
+
expected='[]'; expected_for=""; workflows=""
|
|
164
|
+
pr_expects() { # stdin — a workflow as JSON (null — not parsed); code 0 — wait for its run on a PR into branch $1
|
|
165
|
+
jq -e --arg base "$1" '
|
|
166
|
+
def list: if type == "array" then . else [.] end;
|
|
167
|
+
def rx: "^" + (split("**") | map(split("*") | map(gsub("(?<c>[.^$(){}|\\\\])"; "\\\(.c)")) | join("[^/]*")) | join(".*")) + "$";
|
|
168
|
+
def match_base: list | if any(.[]; tostring | test("[?+\\[!]")) then null else any(.[]; tostring | rx as $r | $base | test($r)) end;
|
|
169
|
+
if type != "object" then true
|
|
170
|
+
else .on
|
|
171
|
+
| if type == "string" then (if . == "pull_request" then {} else null end)
|
|
172
|
+
elif type == "array" then (if any(.[]; . == "pull_request") then {} else null end)
|
|
173
|
+
elif type == "object" and has("pull_request") then (.pull_request // {})
|
|
174
|
+
else null end
|
|
175
|
+
| if type != "object" then false
|
|
176
|
+
elif has("paths") or has("paths-ignore") then false
|
|
177
|
+
elif has("types") and (.types | list | any(.[]; . == "synchronize") | not) then false
|
|
178
|
+
elif has("branches") then (.branches | match_base) == true
|
|
179
|
+
elif has("branches-ignore") then (.["branches-ignore"] | match_base) == false
|
|
180
|
+
else true end
|
|
181
|
+
end' >/dev/null
|
|
182
|
+
}
|
|
183
|
+
load_expected() { # <sha> <base>
|
|
184
|
+
local list path raw doc exp='[]'
|
|
185
|
+
if [ -z "$workflows" ]; then
|
|
186
|
+
list=$(gh api "repos/{owner}/{repo}/actions/workflows?per_page=100" 2>&1) || { absent "$list" || { out=$list; return 1; }; list='{}'; }
|
|
187
|
+
workflows=$(jq -c '[(.workflows // [])[] | select(.state == "active" and (.path | startswith(".github/workflows/"))) | .path]' <<<"$list")
|
|
188
|
+
fi
|
|
189
|
+
if [ "$workflows" != '[]' ] && ! command -v bun >/dev/null; then
|
|
190
|
+
echo "$(ts) bun not found — workflows due on PR are not checked" >&2; workflows='[]'
|
|
191
|
+
fi
|
|
192
|
+
while IFS= read -r path; do
|
|
193
|
+
[ -n "$path" ] || continue
|
|
194
|
+
raw=$(gh api "repos/{owner}/{repo}/contents/$path?ref=$1" 2>&1) || { [[ "$raw" == *"HTTP 404"* ]] && continue; out=$raw; return 1; }
|
|
195
|
+
# not parsed — null; bun itself failing is a poll error, not "not parsed": retry
|
|
196
|
+
doc=$(jq -r '.content // "" | gsub("\n"; "") | @base64d' <<<"$raw" | bun -e '
|
|
197
|
+
const t = await Bun.stdin.text();
|
|
198
|
+
let d = null; try { d = Bun.YAML.parse(t) ?? null } catch {}
|
|
199
|
+
process.stdout.write(JSON.stringify(d))') || { out="bun failed on $path"; return 1; }
|
|
200
|
+
pr_expects "$2" <<<"$doc" && exp=$(jq -c --arg p "$path" '. + [$p]' <<<"$exp")
|
|
201
|
+
done < <(jq -r '.[]' <<<"$workflows")
|
|
202
|
+
expected=$exp; expected_for=$1
|
|
203
|
+
[ "$expected" != '[]' ] && echo "$(ts) workflows due on PR: $(jq -r 'join(", ")' <<<"$expected")" >&2
|
|
204
|
+
return 0
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# poll: checks into $out (see checks). Code 1 — a gh error, 2 — waiting is pointless (ERROR); the text in $out.
|
|
208
|
+
case "$mode" in
|
|
209
|
+
pr)
|
|
210
|
+
[[ "$target" =~ ^[0-9]+$ ]] || finish "ERROR PR number expected, got: $target" 3
|
|
211
|
+
: "${interval:=30}" "${timeout:=1800}"
|
|
212
|
+
label="pr#$target"; conflict_head=""
|
|
213
|
+
poll() {
|
|
214
|
+
local pull base mergeable
|
|
215
|
+
pull=$(gh api "repos/{owner}/{repo}/pulls/$target" 2>&1) || {
|
|
216
|
+
out=$pull; [[ "$pull" == *"HTTP 404"* ]] && { out="PR #$target not found: $pull"; return 2; }; return 1; }
|
|
217
|
+
base=$(jq -r .base.ref <<<"$pull")
|
|
218
|
+
# the head — on every poll: a new push changes the SHA, we must wait for the checks of the current one
|
|
219
|
+
head_sha=$(jq -r .head.sha <<<"$pull")
|
|
220
|
+
label="pr#$target@${head_sha:0:7}"
|
|
221
|
+
# GitHub computes mergeable in the background: null — doesn't know yet, false — a conflict, there is no
|
|
222
|
+
# merge-ref, and workflows on pull_request won't start at all. Right after a push it still gives the false of the
|
|
223
|
+
# previous head, whose conflict was the reason for the rebase (#472): a conflict is the current head's only when
|
|
224
|
+
# the next poll of the same head repeats it; until then — pending, not a PASS
|
|
225
|
+
mergeable=$(jq -r .mergeable <<<"$pull")
|
|
226
|
+
if [ "$mergeable" == false ] && [ "$conflict_head" == "$head_sha" ]; then
|
|
227
|
+
out="conflict with base $base — CI will not run; rebase onto origin/$base"; return 2
|
|
228
|
+
fi
|
|
229
|
+
[ "$mergeable" == false ] && conflict_head=$head_sha || conflict_head=""
|
|
230
|
+
[ -n "$required_loaded" ] || load_required "$base" || return 1
|
|
231
|
+
[ "$expected_for" == "$head_sha" ] || load_expected "$head_sha" "$base" || return 1
|
|
232
|
+
checks "$head_sha" || return 1
|
|
233
|
+
[ -z "$conflict_head" ] || out=$(jq -c --arg n "conflict with base $base: rechecking" '. + [{ name: $n, bucket: "pending" }]' <<<"$out")
|
|
234
|
+
}
|
|
235
|
+
;;
|
|
236
|
+
status)
|
|
237
|
+
[[ "$target" =~ ^[0-9a-f]{40}$ ]] || finish "ERROR sha must be full 40-hex (take it from git: git rev-parse origin/main)" 3
|
|
238
|
+
: "${interval:=20}" "${timeout:=1200}"
|
|
239
|
+
label="${context:-commit}@${target:0:7}"; commit_found=""
|
|
240
|
+
poll() {
|
|
241
|
+
local commit
|
|
242
|
+
# for a nonexistent SHA the API silently returns an empty status — the commit is checked before the checks. "Not
|
|
243
|
+
# found" — only 422 "No commit found" or 404; another failure is a poll error: a one-off API failure is not an outcome
|
|
244
|
+
if [ -z "$commit_found" ]; then
|
|
245
|
+
commit=$(gh api "repos/{owner}/{repo}/commits/$target" --jq .sha 2>&1) || {
|
|
246
|
+
out=$commit; [[ "$commit" == *"HTTP 422"* || "$commit" == *"HTTP 404"* ]] && { out="commit $target not found in repository: $commit"; return 2; }; return 1; }
|
|
247
|
+
commit_found=1
|
|
248
|
+
fi
|
|
249
|
+
checks "$target"
|
|
250
|
+
}
|
|
251
|
+
;;
|
|
252
|
+
merged)
|
|
253
|
+
[[ "$target" =~ ^[0-9]+$ ]] || finish "ERROR PR number expected, got: $target" 3
|
|
254
|
+
: "${interval:=20}" "${timeout:=1200}"
|
|
255
|
+
label="merge#$target"; merge_sha=""
|
|
256
|
+
poll() {
|
|
257
|
+
local pull
|
|
258
|
+
if [ -z "$merge_sha" ]; then
|
|
259
|
+
pull=$(gh api "repos/{owner}/{repo}/pulls/$target" 2>&1) || {
|
|
260
|
+
out=$pull; [[ "$pull" == *"HTTP 404"* ]] && { out="PR #$target not found: $pull"; return 2; }; return 1; }
|
|
261
|
+
[ "$(jq -r .merged <<<"$pull")" == true ] || { out="PR #$target not merged (state $(jq -r .state <<<"$pull")) — nothing to wait for"; return 2; }
|
|
262
|
+
# the SHA of our own merge — from the PR: with parallel merges git rev-parse origin/main returns someone else's commit
|
|
263
|
+
merge_sha=$(jq -r .merge_commit_sha <<<"$pull")
|
|
264
|
+
label="${context:-merge#$target}@${merge_sha:0:7}"
|
|
265
|
+
fi
|
|
266
|
+
checks "$merge_sha"
|
|
267
|
+
}
|
|
268
|
+
;;
|
|
269
|
+
issue)
|
|
270
|
+
[[ "$target" =~ ^[0-9]+$ ]] || finish "ERROR issue number expected, got: $target" 3
|
|
271
|
+
: "${interval:=60}" "${timeout:=7200}"
|
|
272
|
+
label="issue#$target"
|
|
273
|
+
poll() {
|
|
274
|
+
local issue
|
|
275
|
+
issue=$(gh api "repos/{owner}/{repo}/issues/$target" 2>&1) || {
|
|
276
|
+
out=$issue; [[ "$issue" == *"HTTP 404"* ]] && { out="issue #$target not found: $issue"; return 2; }; return 1; }
|
|
277
|
+
# closed — the outcome is known right away: a repeated poll, as for checks, is needed for late-registering checks, not for an issue
|
|
278
|
+
[ "$(jq -r .state <<<"$issue")" == closed ] && finish "PASS issue #$target closed ($(jq -r '.state_reason // "closed"' <<<"$issue"))" 0
|
|
279
|
+
out=$(jq -nc --arg n "issue #$target" '[{ name: $n, bucket: "pending" }]')
|
|
280
|
+
}
|
|
281
|
+
;;
|
|
282
|
+
*) usage ;;
|
|
283
|
+
esac
|
|
284
|
+
|
|
285
|
+
# rate_limit_wait: seconds until the core quota resets; a secondary limit (the quota isn't exhausted) — a minute
|
|
286
|
+
rate_limit_wait() {
|
|
287
|
+
local core w=60
|
|
288
|
+
core=$(gh api rate_limit 2>/dev/null | jq -c .resources.core 2>/dev/null)
|
|
289
|
+
if [ "$(jq -r .remaining <<<"$core" 2>/dev/null)" == 0 ]; then
|
|
290
|
+
w=$(( $(jq -r .reset <<<"$core") - $(date +%s) + 1 ))
|
|
291
|
+
fi
|
|
292
|
+
[ "$w" -lt "$interval" ] && w=$interval
|
|
293
|
+
echo "$w"
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# state <max>: the last poll's checks ($out) in words — how many are done and what we wait for: pending ones, required
|
|
297
|
+
# ones not registered yet, workflows due on the PR without a run at the head; failed ones (--wait-all). A list longer
|
|
298
|
+
# than <max> names is cut ("+N more"), 0 — the whole list.
|
|
299
|
+
state() {
|
|
300
|
+
jq -r --argjson req "$required" --argjson due "$expected" --argjson ran "$ran" --argjson max "$1" '
|
|
301
|
+
def names: sort | if $max > 0 and length > $max then .[:$max] + ["+\(length - $max) more"] else . end | join(", ");
|
|
302
|
+
map(select(.bucket == "pending") | .name) as $wait
|
|
303
|
+
| [ if length == 0 then "no checks yet" else "\(length - ($wait | length))/\(length) done" end,
|
|
304
|
+
($wait | select(. != []) | "waiting: \(names)"),
|
|
305
|
+
($req - map(.name) | select(. != []) | "missing required: \(names)"),
|
|
306
|
+
($due - $ran | select(. != []) | "no run of workflow: \(names)"),
|
|
307
|
+
(map(select(.bucket == "fail" or .bucket == "cancel") | .name) | select(. != []) | "failed: \(names)") ]
|
|
308
|
+
| join("; ")' <<<"$out"
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# dur <seconds>: 45s, 3m05s, 1h20m
|
|
312
|
+
dur() {
|
|
313
|
+
if [ "$1" -lt 60 ]; then echo "${1}s"
|
|
314
|
+
elif [ "$1" -lt 3600 ]; then printf '%dm%02ds\n' $(($1 / 60)) $(($1 % 60))
|
|
315
|
+
else printf '%dh%02dm\n' $(($1 / 3600)) $(($1 % 3600 / 60)); fi
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# say <state>: a state line — only when it differs from the previous one: the output file of a background wait shows
|
|
319
|
+
# what we wait for and since when, not a heartbeat per poll (#432)
|
|
320
|
+
said=""
|
|
321
|
+
say() {
|
|
322
|
+
[ "$label: $1" == "$said" ] && return
|
|
323
|
+
said="$label: $1"
|
|
324
|
+
echo "$(ts) +$(dur $(( $(date +%s) - start ))) $label: $1"
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
start=$(date +%s); deadline=$(( start + timeout ))
|
|
328
|
+
prev_terminal=""; errors=0; quota_wait=0; full="no checks yet"
|
|
329
|
+
while :; do
|
|
330
|
+
poll; rc=$?
|
|
331
|
+
# the interval grows with the wait — a tenth of the elapsed time, up to 5 min: a long deploy is polled several times
|
|
332
|
+
# less often, the REST quota shared by parallel sessions lasts, and the outcome comes at most a tenth of the wait late
|
|
333
|
+
nap=$(( ($(date +%s) - start) / 10 )); [ "$nap" -gt 300 ] && nap=300; [ "$nap" -lt "$interval" ] && nap=$interval
|
|
334
|
+
if [ "$rc" -eq 0 ]; then
|
|
335
|
+
errors=0
|
|
336
|
+
snapshot=$(printf '%s' "$out" | jq -r 'sort_by(.name) | .[] | "\(.name)\t\(.bucket)"')
|
|
337
|
+
total=$(printf '%s' "$out" | jq 'length')
|
|
338
|
+
pending=$(printf '%s' "$out" | jq '[.[] | select(.bucket=="pending")] | length')
|
|
339
|
+
failed=$(printf '%s' "$out" | jq -r '[.[] | select(.bucket=="fail" or .bucket=="cancel") | .name] | join(", ")')
|
|
340
|
+
missing=$(printf '%s' "$out" | jq -r --argjson req "$required" '$req - map(.name) | join(", ")')
|
|
341
|
+
# a workflow that by its `on:` fires on the PR but produced no run at the head is not final, like a required check
|
|
342
|
+
unrun=$(jq -rn --argjson e "$expected" --argjson r "$ran" '$e - $r | join(", ")')
|
|
343
|
+
full=$(state 0)
|
|
344
|
+
say "$(state 5)"
|
|
345
|
+
# a failed check — the outcome is known: long checks alongside it (a preview deploy) won't change it
|
|
346
|
+
[ -n "$failed" ] && [ -z "$wait_all" ] && finish "FAIL $failed" 1
|
|
347
|
+
if [ "$total" -gt 0 ] && [ "$total" -ge "$expect" ] && [ "$pending" -eq 0 ] && [ -z "$missing" ] && [ -z "$unrun" ]; then
|
|
348
|
+
if [ "$snapshot" == "$prev_terminal" ]; then
|
|
349
|
+
[ -n "$failed" ] && finish "FAIL $failed" 1
|
|
350
|
+
finish "PASS ($total checks)" 0
|
|
351
|
+
fi
|
|
352
|
+
prev_terminal=$snapshot
|
|
353
|
+
# the confirming poll — at the first interval: everything has finished, a grown interval would only delay PASS
|
|
354
|
+
nap=$interval
|
|
355
|
+
else
|
|
356
|
+
prev_terminal=""
|
|
357
|
+
fi
|
|
358
|
+
elif [ "$rc" -eq 2 ]; then
|
|
359
|
+
finish "ERROR $out" 3
|
|
360
|
+
elif rate_limited "$out"; then
|
|
361
|
+
# a rate limit is not a call error: the checks go on by themselves, we wait for the quota. That wait doesn't count
|
|
362
|
+
# toward --timeout — the quota resets within an hour, and a reset 40 min away under a 30 min timeout gave TIMEOUT
|
|
363
|
+
# without a single poll after it; at most an hour in total, so a limit that never lifts still ends the wait
|
|
364
|
+
nap=$(rate_limit_wait)
|
|
365
|
+
extra=$nap; [ $((quota_wait + extra)) -gt 3600 ] && extra=$((3600 - quota_wait))
|
|
366
|
+
quota_wait=$((quota_wait + extra)); deadline=$((deadline + extra))
|
|
367
|
+
say "API rate limit — next poll in $(dur "$nap")"
|
|
368
|
+
else
|
|
369
|
+
errors=$((errors + 1))
|
|
370
|
+
echo "$(ts) $label: gh error ($errors): ${out:0:200}" >&2
|
|
371
|
+
[ "$errors" -ge 5 ] && finish "ERROR gh: ${out:0:200}" 3
|
|
372
|
+
fi
|
|
373
|
+
now=$(date +%s)
|
|
374
|
+
[ "$now" -ge "$deadline" ] && finish "TIMEOUT after $(dur $((now - start))) ($full)" 2
|
|
375
|
+
[ "$nap" -gt $((deadline - now)) ] && nap=$((deadline - now))
|
|
376
|
+
sleep "$nap"
|
|
377
|
+
done
|