@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.
Files changed (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +203 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +221 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4854 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +231 -0
  66. package/skills/task-env/proxy/compose.yaml +51 -0
  67. package/skills/task-env/reference.md +254 -0
  68. package/skills/task-env/scripts/task-env.ts +1017 -0
package/package.json CHANGED
@@ -1 +1,70 @@
1
- {"name":"@miroshnik/elves","version":"0.0.0","description":"Elves: placeholder, releases are published by CI"}
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