Compare commits

...

112 commits

Author SHA1 Message Date
92f2eb3949 Merge pull request 'fix: keep import-batch running after token failures' (#69) from build/65-import-batch-token-failure into main
All checks were successful
ci / test (push) Successful in 7m17s
Reviewed-on: heavy-duty/stoke#69
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 08:02:45 +00:00
codex-bot-andresmgsl
37e6a2ad5a test: cover explicit import token handling
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m17s
2026-09-04 07:31:26 +00:00
codex-bot-andresmgsl
82494e94fd test: support node engine floor
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 5m12s
2026-09-04 07:30:26 +00:00
33d583892e Merge pull request 'fix: normalize Debian payload modes' (#68) from build/63-normalize-deb-modes into main
All checks were successful
ci / test (push) Successful in 1m31s
Reviewed-on: heavy-duty/stoke#68
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 07:17:56 +00:00
codex-bot-andresmgsl
4333ce63bf docs: note import-batch token handling
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m18s
2026-09-04 07:00:17 +00:00
codex-bot-andresmgsl
5f2f58a249 test: cover import-batch result boundaries
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 2m6s
2026-09-04 06:59:58 +00:00
codex-bot-andresmgsl
1bb4bd608c fix: isolate import-batch token failures
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 20s
2026-09-04 06:58:57 +00:00
codex-bot-andresmgsl
721ba343cc test: reproduce import-batch token abort
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 18s
2026-09-04 06:58:02 +00:00
codex-bot-andresmgsl
44bbeadff6 docs: record deterministic Debian modes
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 2m38s
2026-09-04 06:28:51 +00:00
codex-bot-andresmgsl
cef903b77e fix: normalize Debian payload modes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 3m1s
2026-09-04 06:28:29 +00:00
codex-bot-andresmgsl
9404c09cae test: expose umask-dependent Debian modes
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 5m21s
2026-09-04 06:27:42 +00:00
74e52b4210 Merge pull request 'fix: keep Debian publish token out of curl argv' (#67) from build/62-publish-deb-token into main
All checks were successful
ci / test (push) Successful in 7m18s
Reviewed-on: heavy-duty/stoke#67
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 06:16:38 +00:00
c4110e7f7e Merge pull request 'fix: report unauthenticated auth state honestly' (#66) from build/64-auth-state into main
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: heavy-duty/stoke#66
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 06:16:20 +00:00
codex-bot-andresmgsl
4d17b8b113 docs: record private Debian publish headers
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 2m10s
2026-09-04 02:56:37 +00:00
codex-bot-andresmgsl
dcb169e8ab fix: protect Debian publish credentials
All checks were successful
labels / labels (pull_request) Successful in 15s
ci / test (pull_request) Successful in 25s
2026-09-04 02:55:49 +00:00
codex-bot-andresmgsl
1dfa2c173d test: cover private deb publish credentials
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 1m8s
2026-09-04 02:54:40 +00:00
codex-bot-andresmgsl
1335232002 test: tolerate Node floor module warning
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 3m14s
2026-09-04 02:28:57 +00:00
codex-bot-andresmgsl
bf84b19a07 docs: record auth state fixes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m18s
2026-09-04 01:56:31 +00:00
codex-bot-andresmgsl
aedce42c56 fix: report unauthenticated auth state honestly
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 4m0s
2026-09-04 01:56:14 +00:00
codex-bot-andresmgsl
ed3f234b8e test: cover unauthenticated auth state
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 2m50s
2026-09-04 01:55:12 +00:00
2230ca2501 Merge pull request 'docs: correct release token scope contract' (#61) from build/60-release-token-scopes into main
All checks were successful
ci / test (push) Successful in 17s
Reviewed-on: heavy-duty/stoke#61
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-03 07:30:21 +00:00
bbde478157 Merge pull request 'fix: clarify publish-deb auth failure' (#59) from build/57-publish-deb-auth-message into main
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: heavy-duty/stoke#59
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-03 07:30:11 +00:00
codex-bot-andresmgsl
0edba09a8a docs: correct release token scopes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 22:54:28 +00:00
codex-bot-andresmgsl
3068809b66 fix: clarify publish-deb auth failure
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 22:39:32 +00:00
codex-bot-andresmgsl
56c8f00d8b test: cover publish-deb token guard
Some checks failed
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Failing after 16s
2026-09-02 22:38:38 +00:00
088e7e2d66 Merge pull request 'chore: release stoke 1.5.0' (#58) from build/56-release-1-5-0 into main
All checks were successful
ci / test (push) Successful in 17s
release / deb (push) Successful in 21s
Reviewed-on: heavy-duty/stoke#58
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-02 20:18:58 +00:00
codex-bot-andresmgsl
e5ead6a0b3 test: decouple missing changelog sentinel
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 18s
2026-09-02 20:03:12 +00:00
codex-bot-andresmgsl
57fc3a3ddf chore: prepare 1.5.0 release
Some checks failed
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Failing after 16s
2026-09-02 19:54:57 +00:00
d6a21c9d9e Merge pull request 'feat: publish releases through stoke CLI' (#55) from build/54-publish-release into main
All checks were successful
ci / test (push) Successful in 16s
Reviewed-on: heavy-duty/stoke#55
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-02 11:22:57 +00:00
codex-bot-andresmgsl
ee88d7d395 ci: delegate release publication to stoke
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 09:24:17 +00:00
codex-bot-andresmgsl
571e1b1f1f feat: publish release assets through stoke
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 09:23:21 +00:00
codex-bot-andresmgsl
e3f4703e13 feat: extract publishable changelog sections
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 17s
2026-09-02 09:21:09 +00:00
codex-bot-andresmgsl
1aa6dc26a1 test: specify changelog section extraction
Some checks failed
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Failing after 16s
2026-09-02 09:19:18 +00:00
967efa027c Merge pull request 'docs: add repository intake forms' (#52) from build/50-issue-templates into main
All checks were successful
ci / test (push) Successful in 15s
Reviewed-on: heavy-duty/stoke#52
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-01 19:14:51 +00:00
codex-bot-andresmgsl
fd22c16069 chore: trigger synchronize verification
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 16s
2026-09-01 15:38:58 +00:00
codex-bot-andresmgsl
da43f96796 docs: add repository intake forms
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-09-01 15:34:46 +00:00
bef059d7b7 Merge pull request 'fix: audit repository scope coverage' (#49) from build/48-scope-coverage into main
All checks were successful
ci / test (push) Successful in 15s
Reviewed-on: heavy-duty/stoke#49
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-09-01 14:16:52 +00:00
codex-bot-andresmgsl
d84062af54 test: enforce scope coverage and parity
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-09-01 13:42:26 +00:00
codex-bot-andresmgsl
112f946802 fix: cover repository scope surfaces
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 16s
2026-09-01 13:40:03 +00:00
081e05ca5a Merge pull request 'docs: document stoke contribution facts' (#47) from build/46-contributing into main
All checks were successful
ci / test (push) Successful in 15s
Reviewed-on: heavy-duty/stoke#47
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-09-01 13:31:30 +00:00
codex-bot-andresmgsl
3ea8eb9032 docs: add stoke contribution facts
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 16s
2026-09-01 12:49:21 +00:00
9586d2c631 Merge pull request 'fix: guard package lock version parity' (#45) from build/43-lockfile-version-guard into main
All checks were successful
ci / test (push) Successful in 27s
Reviewed-on: heavy-duty/stoke#45
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 22:08:18 +00:00
01b25cac43 Merge pull request 'docs: update renamed Kimi login in README' (#44) from build/33-readme-login into main
All checks were successful
ci / test (push) Successful in 17s
Reviewed-on: heavy-duty/stoke#44
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 20:15:22 +00:00
codex-bot-andresmgsl
125bc04ede docs: record lockfile parity guard
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 20:09:37 +00:00
codex-bot-andresmgsl
902ada1623 fix: enforce package lock version parity
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 20:06:46 +00:00
codex-bot-andresmgsl
6bffd8adb0 docs: update renamed kimi login
All checks were successful
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Successful in 17s
2026-08-31 19:48:00 +00:00
21b91e870e Merge pull request 'docs: pin governance records to Forgejo ceremony 0.6.3' (#42) from build/36-ceremony-pin-proof into main
All checks were successful
ci / test (push) Successful in 16s
Reviewed-on: heavy-duty/stoke#42
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 19:46:36 +00:00
codex-bot-andresmgsl
c900f47d77 test: reject ceremony pin suffix drift
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 19:30:33 +00:00
codex-bot-andresmgsl
21fcb1fdb1 docs: qualify every ceremony source record
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 19:29:55 +00:00
codex-bot-andresmgsl
ed16f824ef docs: record governance pin enforcement
All checks were successful
labels / labels (pull_request) Successful in 9s
ci / test (pull_request) Successful in 15s
2026-08-31 19:27:00 +00:00
codex-bot-andresmgsl
c5e0d25c49 Merge remote-tracking branch 'origin/main' into build/36-ceremony-pin-proof
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 16s
2026-08-31 19:25:51 +00:00
c34a8b04d2 Merge pull request 'feat: add fast-forward repo sync' (#41) from build/23-repo-sync into main
All checks were successful
ci / test (push) Successful in 28s
Reviewed-on: heavy-duty/stoke#41
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-08-31 18:42:40 +00:00
codex-bot-andresmgsl
3fac8096f7 test: require exact ceremony workflow pins
All checks were successful
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Successful in 18s
2026-08-31 18:38:54 +00:00
codex-bot-andresmgsl
52e8d45b78 test: pin ceremony governance source
All checks were successful
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Successful in 18s
2026-08-31 18:32:58 +00:00
codex-bot-andresmgsl
316bec5855 fix: reconcile repository sync races
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 17:14:13 +00:00
codex-bot-andresmgsl
04e6ba60e8 docs: explain repository sync boundaries
All checks were successful
labels / labels (pull_request) Successful in 16s
ci / test (pull_request) Successful in 28s
2026-08-31 16:59:10 +00:00
codex-bot-andresmgsl
b21a1387a5 feat: sync safe tags and support dry runs
All checks were successful
labels / labels (pull_request) Successful in 16s
ci / test (pull_request) Successful in 30s
2026-08-31 16:57:02 +00:00
523a45587f Merge pull request 'release: prepare stoke 1.4.0' (#40) from build/32-release-1-4-0 into main
Some checks failed
ci / test (push) Successful in 27s
release / deb (push) Failing after 27s
Reviewed-on: heavy-duty/stoke#40
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 16:56:50 +00:00
codex-bot-andresmgsl
ea6c1a4fe9 feat: resolve repository default branch
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 16:54:34 +00:00
codex-bot-andresmgsl
64b3d9df94 feat: fast-forward repository branches
All checks were successful
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Successful in 16s
2026-08-31 16:51:04 +00:00
codex-bot-andresmgsl
3f943cf921 release: prepare stoke 1.4.0
All checks were successful
ci / test (pull_request) Successful in 21s
labels / labels (pull_request) Successful in 10s
2026-08-31 16:30:20 +00:00
fb5cb4746b Merge pull request 'fix: preserve apt signature verification' (#38) from build/1-apt-signed-install into main
All checks were successful
ci / test (push) Successful in 16s
Reviewed-on: heavy-duty/stoke#38
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 16:05:04 +00:00
codex-bot-andresmgsl
2efc76f23e fix: address apt install review round
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 14s
2026-08-31 15:14:12 +00:00
codex-bot-andresmgsl
9cc9576116 fix: limit apt opt-in to known sqv failure
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-08-31 10:57:32 +00:00
codex-bot-andresmgsl
a89eafaebc fix: require opt-in for unverified apt sources
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-08-31 10:48:42 +00:00
claude-lead-andresmgsl
9f34641545 fix: stop attributing the sqv rejection to the key algorithm
All checks were successful
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Successful in 17s
Refs #1.

`scripts/install-apt.sh` explained the sqv rejection as a malformed MPI
encoding in an Ed25519 key. The registry does not serve one: at
`api/packages/heavy-duty/debian/repository.key`, `gpg --list-packets`
reports `algo 1` (RSA) with a 2048-bit `pkey[0]`. The explanation was
therefore wrong about the only part of itself that was checkable.

The comment now states the measurement and points at #1 for the cause,
rather than naming a mechanism nobody verified. This satisfies #1's
criterion that `git grep -in 25519` return no hits outside test fixtures,
which it now does.

Behaviour is unchanged: this commit edits a comment. The automatic
fallback this file still performs contradicts ruling B and is NOT fixed
here — that work is still owed on !38.
2026-08-31 10:39:02 +00:00
25c7267ecd docs: move the .ceremony/ pin record to 0.6.3
All checks were successful
ci / test (push) Successful in 14s
The mirror was re-vendored from ceremony 0.6.3; this file still named 0.6.1 as
the pin in both places.
2026-08-30 22:15:58 +00:00
5ec01f56b7 docs: re-vendor .ceremony/ from ceremony 0.6.3
Some checks failed
ci / test (push) Has been cancelled
#39 moved the two workflow callers to 0.6.3 but left the doctrine mirror at
0.6.1, so BUILDER.md was missing the three clauses that govern signal-then-ready
(ceremony#330, the undraft order, and ceremony#336). Re-vendored all six
manifest files and moved the pin recorded in .ceremony/README.md.
2026-08-30 22:15:50 +00:00
f9a8ad46f4 docs: re-vendor .ceremony/ from ceremony 0.6.3
Some checks failed
ci / test (push) Has been cancelled
#39 moved the two workflow callers to 0.6.3 but left the doctrine mirror at
0.6.1, so BUILDER.md was missing the three clauses that govern signal-then-ready
(ceremony#330, the undraft order, and ceremony#336). Re-vendored all six
manifest files and moved the pin recorded in .ceremony/README.md.
2026-08-30 22:15:49 +00:00
6cd2bb583a docs: re-vendor .ceremony/ from ceremony 0.6.3
Some checks are pending
ci / test (push) Waiting to run
#39 moved the two workflow callers to 0.6.3 but left the doctrine mirror at
0.6.1, so BUILDER.md was missing the three clauses that govern signal-then-ready
(ceremony#330, the undraft order, and ceremony#336). Re-vendored all six
manifest files and moved the pin recorded in .ceremony/README.md.
2026-08-30 22:15:47 +00:00
1c6d8cce73 docs: re-vendor .ceremony/ from ceremony 0.6.3
Some checks failed
ci / test (push) Has been cancelled
#39 moved the two workflow callers to 0.6.3 but left the doctrine mirror at
0.6.1, so BUILDER.md was missing the three clauses that govern signal-then-ready
(ceremony#330, the undraft order, and ceremony#336). Re-vendored all six
manifest files and moved the pin recorded in .ceremony/README.md.
2026-08-30 22:15:46 +00:00
cca75fef3e docs: re-vendor .ceremony/ from ceremony 0.6.3
Some checks failed
ci / test (push) Has been cancelled
#39 moved the two workflow callers to 0.6.3 but left the doctrine mirror at
0.6.1, so BUILDER.md was missing the three clauses that govern signal-then-ready
(ceremony#330, the undraft order, and ceremony#336). Re-vendored all six
manifest files and moved the pin recorded in .ceremony/README.md.
2026-08-30 22:15:45 +00:00
125e44afe9 docs: re-vendor .ceremony/ from ceremony 0.6.3
Some checks are pending
ci / test (push) Waiting to run
#39 moved the two workflow callers to 0.6.3 but left the doctrine mirror at
0.6.1, so BUILDER.md was missing the three clauses that govern signal-then-ready
(ceremony#330, the undraft order, and ceremony#336). Re-vendored all six
manifest files and moved the pin recorded in .ceremony/README.md.
2026-08-30 22:15:44 +00:00
92ba146b44 fix: bump ceremony pin to 0.6.3
All checks were successful
ci / test (push) Successful in 14s
Closes #39. 0.6.1 has no draft guard in its Forgejo mergeability mapping, so
the sweep marks every draft PR as blocker:conflict (see !38). The guard landed
in ceremony d3b7984, first tagged 0.6.2.
2026-08-30 19:28:13 +00:00
4a62f7e0cf fix: bump ceremony pin to 0.6.3
Some checks are pending
ci / test (push) Waiting to run
Closes #39. 0.6.1 has no draft guard in its Forgejo mergeability mapping, so
the sweep marks every draft PR as blocker:conflict (see !38). The guard landed
in ceremony d3b7984, first tagged 0.6.2.
2026-08-30 19:28:12 +00:00
033a40c7a0 Merge pull request 'feat: upload release assets' (#37) from build/25-release-assets into main
All checks were successful
ci / test (push) Successful in 13s
Reviewed-on: heavy-duty/stoke#37
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-30 13:12:37 +00:00
codex-bot-andresmgsl
769a3c8aba fix: read apt credentials through sudo
All checks were successful
labels / labels (pull_request) Successful in 9s
ci / test (pull_request) Successful in 13s
2026-08-30 11:40:41 +00:00
codex-bot-andresmgsl
c7971eefe0 docs: document authenticated apt installs
All checks were successful
labels / labels (pull_request) Successful in 9s
ci / test (pull_request) Successful in 13s
2026-08-30 11:35:08 +00:00
codex-bot-andresmgsl
a28b2ffd74 feat: support private apt registry credentials
All checks were successful
labels / labels (pull_request) Successful in 9s
ci / test (pull_request) Successful in 13s
2026-08-30 11:34:06 +00:00
codex-bot-andresmgsl
acb46d0707 fix: keep apt signature verification on transient failures
All checks were successful
labels / labels (pull_request) Successful in 9s
ci / test (pull_request) Successful in 14s
2026-08-30 11:31:24 +00:00
codex-bot-andresmgsl
3c0709189e fix: validate release asset uploads
All checks were successful
labels / labels (pull_request) Successful in 8s
ci / test (pull_request) Successful in 14s
2026-08-30 11:08:01 +00:00
codex-bot-andresmgsl
8293c83531 test: cover backpressured release uploads
All checks were successful
labels / labels (pull_request) Successful in 8s
ci / test (pull_request) Successful in 14s
2026-08-30 10:07:32 +00:00
codex-bot-andresmgsl
1371ec9aa9 docs: document release asset workflow
All checks were successful
labels / labels (pull_request) Successful in 8s
ci / test (pull_request) Successful in 13s
2026-08-30 09:58:06 +00:00
codex-bot-andresmgsl
d1c80db665 feat: add release asset commands
All checks were successful
labels / labels (pull_request) Successful in 8s
ci / test (pull_request) Successful in 13s
2026-08-30 09:56:21 +00:00
codex-bot-andresmgsl
0fac095509 feat: stream release asset uploads
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 17s
2026-08-30 09:48:02 +00:00
c09943ea32 Merge pull request 'feat: support organization-owned repository creation' (#35) from build/24-repo-create-owner into main
All checks were successful
ci / test (push) Successful in 24s
Reviewed-on: heavy-duty/stoke#35
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-21 23:18:38 +00:00
codex-bot-andresmgsl
ccaeb8ee4e docs: document repo create owner routing
Some checks failed
labels / labels (pull_request) Successful in 20s
ci / test (pull_request) Has been cancelled
2026-08-21 06:37:04 +00:00
codex-bot-andresmgsl
914e4c444b feat: support organization-owned repo creation
Some checks failed
labels / labels (pull_request) Failing after 19s
ci / test (pull_request) Has been cancelled
2026-08-21 06:35:41 +00:00
95f9eb8060 Merge pull request 'Adopt ceremony labels and review machinery' (#31) from build/30-ceremony-adoption into main
All checks were successful
ci / test (push) Successful in 24s
Reviewed-on: heavy-duty/stoke#31
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-21 06:31:24 +00:00
codex-bot-andresmgsl
db36cf2efc Fix governance roster and mirror claims
All checks were successful
ci / test (pull_request) Successful in 24s
2026-08-20 23:23:51 +00:00
codex-bot-andresmgsl
47aed6f1de Vendor ceremony doctrine and scope mapping
All checks were successful
ci / test (pull_request) Successful in 23s
2026-08-19 20:56:10 +00:00
codex-bot-andresmgsl
9efe4bf717 Verify governance configuration in CI
All checks were successful
ci / test (pull_request) Successful in 23s
2026-08-19 19:26:29 +00:00
codex-bot-andresmgsl
a935b84ef8 Add ceremony label workflows
All checks were successful
ci / test (pull_request) Successful in 22s
2026-08-19 19:25:15 +00:00
codex-bot-andresmgsl
e86ce95180 Add governance config validation
All checks were successful
ci / test (pull_request) Successful in 24s
2026-08-19 19:23:21 +00:00
4c6185898e Merge pull request 'Add label support to issue creation' (#29) from build/26-issue-create-labels into main
All checks were successful
ci / test (push) Successful in 22s
Reviewed-on: heavy-duty/stoke#29
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: cluade-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-19 19:15:33 +00:00
codex-bot-andresmgsl
a62a753656 Document issue create labels
Some checks failed
ci / test (pull_request) Has been cancelled
2026-08-18 00:46:47 +00:00
codex-bot-andresmgsl
7b372eb2dc Add labels to issue creation
Some checks failed
ci / test (pull_request) Has been cancelled
2026-08-18 00:45:00 +00:00
ee0cb85c7b Merge pull request 'Add issue show/comment, --json output, and pr review --commit' (#20) from feat/issue-cmds-json-review-commit into main
Some checks failed
ci / test (push) Has been cancelled
ci / test (pull_request) Has been cancelled
2026-07-26 22:01:19 +00:00
kimi-reviewer-andresmgsl
0531bde366 Add issue show/comment, --json output, and pr review --commit
Some checks failed
ci / test (pull_request) Has been cancelled
2026-07-26 22:00:47 +00:00
87b3cf98d9 Merge pull request 'auth login: default to least-privilege token scopes' (#19) from fix/auth-login-scopes into main
Some checks are pending
ci / test (push) Waiting to run
2026-07-26 22:00:12 +00:00
907917a870 Merge pull request 'install-apt: fail fast when the registry has no Release file' (#18) from fix/install-apt-fail-fast into main
Some checks are pending
ci / test (push) Waiting to run
2026-07-26 22:00:09 +00:00
c85be2e083 Merge pull request 'Add CI workflow for PRs and main pushes' (#17) from ci/pr-main-tests into main
Some checks are pending
ci / test (push) Waiting to run
2026-07-26 22:00:00 +00:00
kimi-reviewer-andresmgsl
955ce393fc auth login: default to least-privilege token scopes (#9)
Tokens minted by stoke auth login previously got read/write on every
non-admin scope. Default to the reduced set the common issue/PR/repo
commands need (read/write issue + repository, read user + organization),
add --full-scopes to restore the old behavior and --scopes <csv> for a
custom list, and print the granted scopes after login.
2026-07-26 21:43:06 +00:00
kimi-reviewer-andresmgsl
8255c568b1 install-apt: fail fast with a clear message when the registry has no Release file 2026-07-26 21:41:16 +00:00
kimi-reviewer-andresmgsl
b4b38d1d97 Add CI workflow for PRs and main pushes
Some checks failed
ci / test (pull_request) Has been cancelled
2026-07-26 21:39:57 +00:00
1165ee22c3 Merge pull request 'design: stoke brand system (replaces #12, without the 23MB of binaries)' (#16) from design/brand-system-v2 into main
Reviewed-on: heavy-duty/stoke#16
2026-07-26 21:28:22 +00:00
f5a44021da design: stoke brand system, without the 23MB of binaries
Replaces #12, which committed every generated render into the repo. Rebuilt on
a clean branch because merging the original would have written those blobs into
main's history permanently, even with a later commit deleting them.

What changed from #12:

- Keeps docs/DESIGN.md and assets/logo-mark.svg (1.4 kB of vector text).
- Drops ~23 MB of PNG/MP4. They live in the Figma file, which was already the
  source of truth and is linked from the doc. stoke's .git is ~23 MB; those
  assets would have doubled it, forever.
- Adds the missing "files" whitelist to package.json. There wasn't one, so
  npm pack shipped the whole working directory: measured 23.7 MB with the
  assets, and it was already shipping the test suite without them. Now 26.7 kB
  across 7 files.

The packaging bug is pre-existing and independent of the design work; the
oversized PR is just what made it visible.

61/61 tests pass; `stoke --version` → 1.3.0.
2026-07-26 21:26:30 +00:00
3e93b20ae6 Add stoke repo clone with ephemeral token handling (#14)
Closes #13.

Independently verified end-to-end: token absent from .git, clean remote URL,
no extraHeader persisted. 53/53 tests pass.
2026-07-26 21:24:42 +00:00
f4b0bdbe4e Merge pull request 'Add release, label, and api commands (v1.3.0)' (#15) from feat/releases-labels-api into main
Some checks failed
release / deb (push) Has been cancelled
Reviewed-on: heavy-duty/stoke#15
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-07-26 21:02:23 +00:00
036364f844 Address review: pager owns limit/page, reject GET+input, label delete exclusivity 2026-07-26 20:52:29 +00:00
355fcc1f67 Add release, label, and api commands (v1.3.0) 2026-07-26 20:31:07 +00:00
1b990d523a Add stoke repo clone with ephemeral token handling (#13)
Clone repositories from the configured Forgejo instance using the stored
session. The token is passed to git through GIT_CONFIG_* environment-based
config (http.<url>.extraHeader) with GIT_TERMINAL_PROMPT=0, so it never
appears in the remote URL, on the command line, in logs, or in the cloned
repository's .git/config. Git streams its own output and its exit status is
forwarded to the caller.

Supports an optional destination directory plus --branch, --depth and
--origin. Adds tests covering destination handling, exit-status
propagation, remote naming, depth validation and credential redaction.
2026-07-23 22:44:21 +00:00
50 changed files with 6052 additions and 112 deletions

49
.ceremony/AGENTS.md Normal file
View file

@ -0,0 +1,49 @@
# AGENTS.md — start here
You are an agent working in a repo governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
the router: find your role below, read its file, then act. The role files
sit beside this one — in ceremony itself at the repo root, in a governed
repo under `.ceremony/` (a machine-managed mirror; never edit those files
in place — they are changed in heavy-duty/ceremony, through its own flow).
## Your role
You were told your role when you were pointed at this repo ("you are a
reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line |
|---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
are the shared state machine, and misusing one lies to every other agent on
the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process proposals or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints work issues.** Found work? File or extend a proposal.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button.
## Repo specifics
What is true only of *this* repo — the review panel roster, the `scope:*`
label set, what a drill means, code conventions — lives in the repo's own
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.

309
.ceremony/BUILDER.md Normal file
View file

@ -0,0 +1,309 @@
# BUILDER.md — the builder role
You turn one issue into one PR. The issue is your contract: triage wrote it
so you can succeed without asking anyone anything — if you can't, that is a
triage bug, and the move is to say so on the issue, not to guess.
## Picking
- Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an
`epic` (epics organize; their children are the work). Inside an epic take
the earliest unblocked unclaimed child, otherwise the issue that unblocks
the most work; where a repo adopts version epics,
[RELEASES.md](RELEASES.md) governs among window members.
- **Your own red head outranks a new claim**: repair a failing check at your
PR's head before claiming another issue (#163). Red and green here are the
review round's ruled terms: cancelled, stale, or unreported — every entry
at the head cancelled — is not green; skipped or neutral is. Record the
check and its failure class; rerun a clearly retryable infrastructure
failure unchanged; treat a branch failure as an ordinary fix round,
worklog and all; leave evidence where a rerun cannot start or the cause is
unclear; never rerun a deterministic failure without a corrective commit;
hand off once green with current-head approvals. Such a PR is **never
parked**, whatever the verdict state says; how the engine detects a red
head is crew's to describe.
- **One build at a time**: one issue on which you are writing or revising a
deliverable, finished or released before you start more. The rule counts
work in flight, not claims — a **parked** claim, whose next move is
someone else's, does not hold the slot. Five shapes park:
1. `needs-ruling` is set, the escalation names a decider, and its
`Blocked:` line stops the rest;
2. a **live** review round holds it, every outstanding verdict someone
else's — awaiting first verdicts, or answered whole with the owed
re-requests posted, by head and not by verdict (steps 12). A red check
at the head takes it out of this shape: the next move is yours;
3. every remaining acceptance criterion is operator-owned, stated so by
triage on the issue. **An operator-owned remainder parks the claim and
never the handoff**: this shape is reached only from the far side of
shape 4, because it is the state finishing the work puts you in and
would otherwise excuse the handoff it should follow (#336);
4. it is **handed off** — round passed, no `blocker:*` standing,
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
and 4 are sequential and never overlap;
5. the claim is **held by directive** — triage or the operator stopped the
work, named what the hold waits on, and only they end it. A hold ends
as it started, **on the labels**: where labels and prose disagree, the
most recent queue-label event by the hold's owner governs, and an
operator may lift by label alone (#149, #151). So read the label events
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the
comments, before standing down *or* up, and say in the claim which you
read, their timestamps and their actor. Where they do not resolve the
contradiction, say so and take the next `ready` issue; refusing is no
resting place.
Not parked: waiting on yourself, on CI (a red head is yours; a pending one
resolves without you), or for a good moment. An issue you stopped working
on is abandoned — unassign and restore `ready`. Parked claims are held
beside the one active build (#15, #16, #73).
## Claiming
- Assign yourself, swap `ready``claimed`, and comment that you are
starting. The claim promises a draft PR soon: a claim with no PR and no
activity is what the staleness sweep reclaims, unless `offsite` records
that its PR lives in another repo.
- **A park is declared, never inferred.** Comment naming what the claim
waits on and who owns the next move — no new label; the comment is the
activity the reclaim clock reads, as for `needs-ruling` (#52) and
`offsite` (#68). Shape 4 is exempt: the handoff comment and
`state:needs-human` already say both.
- **A declaration stands until the park's facts change**, so a resumption
finding nothing changed posts nothing (#177). Each change owes one comment
— the wait resolves or changes hands, the shape changes, the claim
unparks. A parked claim with **no open PR** still feeds the 48-hour
reclaim clock, so refresh the declaration before it closes; that is a
park's only repeat.
- **Pick up `attention` before anything else**: post a short pickup comment
and remove the label, which is the ack. A demand on a parked claim is
usually its unpark, so take the slot back — unless the demand *is* the
park, the pickup comment then doubling as the declaration.
- **A directed hold keeps its bookkeeping visible.** The PR carries
`blocked` with a comment naming what it waits on; the issue stays
`claimed` and carries `attention` until the builder acks. Nobody unassigns
it, and the 48-hour reclaim does not fire while the claim has an open PR.
- **Unparking is a claim like any other** and takes the slot: if you are
active elsewhere, finish or release that work first and say which on both
issues. No machinery counts claims per builder, and none should be built
expecting this section to have specified one.
- **Abandoning is fine; ghosting is not.** Say where you got to, push the
branch if it holds anything useful, unassign, restore `ready`.
## Building
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
body. Drafts are invisible to the panel on purpose: that phase is yours.
- **`Closes #N` does not cross repos.** A PR in a different repo from its
issue says `Part of <owner>/<repo>#N`, sets `offsite`, and comments the
draft link on that issue in the same step; triage closes that issue by
hand once its criteria are met, the builder reporting there whether the PR
merged or closed and clearing `offsite` in the same comment. The
cross-repo merge never closes the authorizing issue (#13, #16).
- **`Closes #N` does not survive a post-merge criterion.** Where the issue
body says a criterion can only be checked after the merge — a workflow
trigger proved live, a released artifact, anything whose subject does not
exist until the change is on the base branch — the same-repo PR says
`Refs #N`; the issue goes `post-merge` at the merge, the builder walks
away, and triage owns verification and closure on the evidence, returning
the issue to `ready` or minting a fresh one where corrective work is
needed — claimable by any builder from current `main`, the original having
no special standing. The issue body says so — you never judge which
qualify — and absent it `Closes #N` is the default (#151).
- On a `Refs #N` PR, never put a closing keyword (`close`, `closes`,
`closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`)
immediately before `#N` anywhere in the body, including the sentence
explaining why the PR does not close it: GitHub reads the body by
adjacency, not intent, and a code span does not protect the phrase (#200,
#218). Put the number first (`#N is closed by hand`) or omit it.
- **The issue's acceptance criteria are your definition of done**: reproduce
them as a checklist in the PR body and check them honestly. One that turns
out wrong or unreachable goes back to triage to be amended, never silently
shipped short.
- **Every behavior change writes one fragment**, `changelog.d/<issue>.md`
named for the authorizing issue (`<repo>-<issue>.md` cross-repo): the
prose to be published and nothing else — `- ` bullets, plus in a grouped
repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only
where a change genuinely is one. An entry is at most 300 characters, so a
long change ships several short ones (wrapping over continuation lines is
free), and it **ends with its issue citation**: a parenthesised group of
`#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and
nothing after — `(#262).`, `(#236, #250).` — which need not name the
fragment's own issue, the filename carrying it. The guard reds a long
entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the
release PR assembles the section from fragments (#112), and the monotonic
guard refuses anything deleting a shipped heading.
- Follow the repo's conventions file and match the code you touch. Tests are
not optional: the issue's test plan is the floor, not the ceiling.
- **A write-capable job gets a repo-owned script, not a third-party
action.** Where the token can write (`packages: write`, `contents: write`,
`id-token: write`, deploy secrets), default to a script a test can drive;
a third-party action there needs an established publisher and a
full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and
its red-flag profile are in REVIEWER.md §What you review against, item 2
(#216).
- **Scope discipline: the PR does the issue — whole, and nothing else.**
Adjacent problems go to a proposal, or a comment on the relevant issue;
you do not mint work issues — nobody but triage does — and you do not fix
drive-by findings in the same PR.
## The review round
(In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts
such as the panel roster live in that repo's own CONTRIBUTING.)
1. Mark ready-for-review; request **the whole panel**: the PR repo's
`panel[<your-login>]=` line if it defines one, else its `panel=` line,
minus the author (#224) — never the roster of the repo the issue is in.
That repo's `.github/labels.conf` governs over its CONTRIBUTING roster,
being what the state machine reads; where it names no roster, ask triage
on the authorizing issue rather than guess. An off-panel reviewer may be
requested, said to be advisory and not required.
**A review request requires a green check at the head**, whether or not
an engine enforces it: a red check is the author's own signal, so fix it
and push, then request. The one exception is a failure genuinely outside
the PR — a runner outage, a flaky dependency, a failure already on the
default branch — and only where the request says so and names the
evidence ("the same job fails identically on `origin/main` at `<sha>`");
silence about a red check is what is prohibited, and an argued exception
shifts the burden to the author.
*Green* is a ruled term (operator, 2026-07-27), read in two steps.
**First take the check's word at this head**: its newest entry by start
time — not completion, a cancelled run outliving its replacement's start
— and never a `CANCELLED` entry while the same check has a non-cancelled
one there. A check whose entries at the head are all cancelled has not
reported at all and is not green — a collapse, not a new class, and the
gate partitions alike, dropping a cancelled entry only where a
non-cancelled survivor remains and leaving an all-cancelled context
blocking (#139, #276). **Then classify that entry by `conclusion`, never
`status`**, which can disagree with it (#259). No conclusion is not
green: a configured run in progress is waited on, and waiting is
compliance, not a stall — the wait is the **request's**, and never a
reason to withhold the declaration that a round was answered (step 2).
Cancelled or stale is not green, *stale* being a
superseded head's check, which a head-scoped rollup never shows. Skipped
or neutral is green, those being deliberate "passed / not applicable"
conclusions. No checks configured is green — the third ruled case, not an
argued exception, so the request goes out at once with no evidence owed;
that never covers nothing-answered-yet, and the machine partitions alike,
admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the
line are asymmetric: a false green spends a three-reviewer round, a false
red one author session. What the machine drops from the rollup before
grading is crew's to describe.
2. **Wait for every verdict, then answer the round whole** — one reply
covering every point, stating what changed and what was verified. That
reply is the written record: the engine mirrors it under the PR body's
**Round log**, newest last and marked with the round's head, which makes
a retry a no-op; you owe the reply and no body edit, and a round answered
without one is recorded as such and never blocks handoff. Then push the
fixes and re-request **by head, not by verdict**. A push makes every
approval stale — an approval is of a specific tree, and the handoff
predicate counts only approvals at the current head — so **every panelist
is re-requested, approvers included**; one left un-re-requested can never
approve the tree you shipped (#26, #39). Only where the head did not move
— answered with argument or evidence, nothing pushed — do you re-request
just the non-approvers; the engine absorbs a re-request at an unchanged
head, and its mechanism is crew's to describe (#94). **The re-request
carries the same green-check-at-head precondition**, argued exception
included: a fix push whose check comes up red is your next fix, not the
panel's. **Where an engine mediates the request, that precondition binds
the engine's act and not yours**: declaring a round answered is not
requesting the panel, so declare it as soon as the round's fixes are
pushed and stop. The engine holds the request while the head is pending
or red, so an early declaration cannot produce an early request while a
withheld one is indistinguishable from a session that died (#330).
**Never wait on an event you have no wake for** — where the engine is
what observes the check settling, the wait is the engine's to keep
(#330). **Never block on a producer you cannot prove alive either**:
where a job signals its own completion, that signal is the wake and the
finished output is read afterwards, because a follow on a file nothing is
writing cannot tell *not yet* from *never* (#336). Prefer verification
over argument — add the test that settles the doubt.
3. Never dismiss a review, never merge, never mark your own work as passed.
A blocking point you disagree with is answered with evidence or escalated
in the PR; silence and force-forward are not options, and a panel
deadlock is one kind of human-owned decision (#50 D11).
**A fix round may ride a draft**, and the draft changes nothing about who
owes what: a mid-round draft reads as a draft always read — the phase is
yours, the panel cannot see it — while the round outranks it, so you owe the
round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s
`state:building` row, #205). **Ready-for-review is the act that ends the
round, and it is the builder's alone**: the flip asserts the round was
answered whole, the one judgement its author cannot delegate, so an engine
may draft a PR but only the builder undrafts it. **Where a draft suppressed
the checks, green is proven at the flip and the request still follows it** —
marking ready runs the checks the draft held back, so the order is flip, let
the head answer, then request, step 1's precondition and not a second one.
Waiting there is compliance — again the request's wait, not the
declaration's — and `blocker:unrequested` does not fire while a head's
checks are pending or red (#236).
## The ruling ask
Set `needs-ruling` whenever a decision belongs to a human: org policy,
published artifacts, secrets, prod, or any choice whose cost lands outside
the PR — a panel deadlock is one instance, not the definition. The builder
is the PR's accountable flag-setter and consolidates the decision into one
comment rather than forwarding several reviewers' phrasings (#50 D11).
Keep at most these five lines above the fold, all other analysis inside it.
The field labels are fixed because the ruling machinery checks for them (#50
D12):
```text
🧭 needs-ruling — <the decision, one line>
Options: A — <one clause> B — <one clause>
Recommend: A, because <one clause>.
Blocked: <what stops; what continues meanwhile>
Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
<details><summary>Analysis</summary>…everything else…</details>
```
The options must be exhaustive and mutually exclusive; more than three means
the question is not ready. `Recommend:` is mandatory — omitting it hands the
whole problem to the human. `Blocked:` names both what stops and what
continues. Write a timed `Default:` only when affirmatively confident the
decision is reversible inside the PR before merge; unsure is not a tie but a
hard block, as published artifacts, secrets, prod and org policy are by
construction (#50 D12D13).
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
event**, not its `Default:` deadline or the last activity (#50 D13D14):
- **012h:** proceed when a still-clear, reversible default expires, saying
out loud that you did; a hard block waits.
- **at 12h:** do not fire a stale default — re-read it against what has
landed, and where doubt has appeared, make it a hard block.
- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the
body which way you went and what doubt remains. Nothing merges by this;
the human still gates the merge.
- **past 24h:** hand the choice to triage, which picks the option, records
it as a decision, and stays accountable; the operator can overturn it at
merge.
A re-flag starts a fresh ladder, which applies whatever `Default:` says,
hard block included, and an active back-and-forth still climbs it — unlike
the 7-day nudge, which resets on real activity. The machine observes both
clocks but never sets, clears, or decides `needs-ruling`. The label stays
until agreement is *reached*, not until the maintainer replies: the setter
records the ruling, removes the label, and returns the item to its flow in
the same comment ([LABELS.md](LABELS.md)).
## Handoff
When the round passes — every panel verdict approving the **current head**,
no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this
is a release PR) — the engine does these steps for the builder, in order:
1. request the human's review;
2. set `state:needs-human`;
3. post the engine-rendered handoff comment: approvals at the current head,
the head SHA, and a pointer to the PR body's **Round log**.
The builder composes no new summary: the authored record already lives in
the Round log, mirrored from each whole-round reply. The label write is
optimistic — the reconciler validates it and takes it back if the PR is not
mergeable-right-now. Then stop: the PR is the human's, and the claim parks
as shape 4 (Picking, above), that comment its declaration and your slot
free. Address what comes back (`state:addressing`) and re-hand-off the same
way.

245
.ceremony/LABELS.md Normal file
View file

@ -0,0 +1,245 @@
# Labels
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by
the labels workflow's dispatch (which is also the operator's manual
full-board reconcile sweep; issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, reconciled by the work-queue sweep). One rule joins
everything: **states are machine-owned, intent
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
and the reconciler recomputes it from GitHub's own facts.
## PR state — who is the ball with? (exactly one per open PR)
| Label | Color | Waiting on |
|---|---|---|
| `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) |
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
*poke the reviewers*, in the second *the builder dropped the ball*. And
`state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one
hand-set state). On a same-repository head, the `labeled` event fires the
sweep that validates the write within seconds; on a fork head whose
`pull_request_target` token is read-only, validation waits for the scheduled
sweep cadence (#241).
## PR blockers — what is in the way? (facts, as many as apply)
| Label | Color | Means |
|---|---|---|
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
States answer *whose ball*; blockers answer *what's in the way*. They are
separate axes because the single-label version kept lying — independent facts
projected onto one totally-ordered label meant one always won and the losers
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
strips it on sight).
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
| `post-merge` | `#006B75` | the Refs-linked PR merged; post-merge acceptance criteria remain; the claim is released — nothing here is buildable and nobody owes a draft | the sweep or triage |
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
`blocked` / `post-merge`. It flags conflicts rather than guessing intent. A `claimed` issue
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
comments, unassigns the stale owner, and restores `ready`.
When a merged PR references a `claimed` issue with `Refs #N` and unchecked
criteria remain, the sweep moves the issue to `post-merge`, clears the
assignee, and comments with the remaining criteria verbatim. The comment says
that the claim is released and that triage owes a follow-up naming the owner
and wake condition for completion. Triage writes that full transition comment
in the same tick when it or the operator makes the move by hand. The sweep
never reclaims `post-merge`: weeks of quiet can be the state working. It does
make the quiet visible — after 7 days with no comment on the issue, the sweep
posts one nudge naming the triage actor, saying the wake evidence is owed and
linking the item. Only a comment resets that clock: label churn does not, and
neither does an assignment, which is the claim clock's fact and on this queue
state is the invalid composition flagged below. Which criterion starved is
prose the machine never judges; the link is the payload. Like the ruling nudge
it carries no idempotency marker on purpose — the comment is itself activity,
so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no
label.
`post-merge` never composes with `blocked`; the transition comment carries the
wait. It never composes with `attention`, because releasing the claim clears
the assignee and leaves nobody parked-for. An assigned `post-merge` issue is
flagged rather than repaired: a hand-assignment is intent. `needs-ruling`
still composes. When the remainder becomes buildable, triage moves
`post-merge` to `ready` or mints a fresh `ready` issue. Any builder may claim
that work from current `main`; the original builder has no special standing,
and re-entry does not set `attention`.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it). The reconciler refuses `state:needs-human` while `blocked` stands — the PR falls to `state:addressing` (#180) |
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
`needs-ruling` marks where the human's turn is when the pending thing is a
*decision*, not a merge ([#50 D1D14](https://github.com/heavy-duty/ceremony/issues/50)).
It applies to any human-owned decision — org policy, published artifacts,
secrets, prod, or any choice whose cost lands outside the work. A panel
deadlock is one instance, not the definition (D11). It is not
`state:needs-human`: that label means exactly "this PR could be merged right
now", and the retired `state:needs-rebase` is the family's proof that a
label meaning two things lies about both. It is not a `blocker:*` either:
every blocker names work the *builder* owes, a ruling is owed by the human —
and the flag must live on issues too, where blockers do not exist. On issues
it coexists with the queue labels (the one-of-three invariant above ignores
it); its color is the light shade of `state:needs-human`'s, so the human
axis reads as one family. It is a state, not a signal: set only with the
[canonical escalation contract](BUILDER.md#the-ruling-ask) (D12). A bare
flag is noise. The comment carries exhaustive, mutually exclusive options
(at most three), a mandatory recommendation, what stops and what continues,
and either a default affirmatively known to be reversible inside the PR or
`none — hard block`. Unsure is a block; published artifacts, secrets, prod,
and org policy are hard blocks by construction (D13).
The ruling ladder runs from the current episode's `needs-ruling` **`labeled`
event** (D13D14):
- **012h:** a clear, reversible decision may proceed when its stated default
expires, saying out loud that it did; anything with reasonable doubt waits
as a hard block.
- **at 12h:** the setter re-reads the default against what has landed and asks
whether it still holds and whether doubt remains. A stale default does not
fire; new doubt makes it a hard block.
- **at 24h:** the builder proceeds regardless, **as a PR**, stating the option
chosen and the doubt that remains. Nothing merges by this; the human still
gates the merge.
- **past 24h:** triage picks the option, records it as a decision, and remains
accountable. The operator may overturn it at merge.
A re-flag starts a new ladder. The rungs apply whatever `Default:` says,
including a hard block. Active discussion still climbs the ladder; by
contrast, the separate 7-day nudge resets on real activity. The machine
observes the rungs but never sets, clears, or decides `needs-ruling`.
The flag stays up until agreement is *reached* — a human reply alone does not
clear it — and its setter closes it out: records the ruling as a decision in
one comment, removes the label, and returns the item to its flow in that same
comment, never as a side effect. If the human disagrees that agreement was
reached, the label goes back on. The reconciler refuses `state:needs-human`
while it stands (the PR falls to `state:addressing` — the ball on the PR is
the builder's, who carries the ruling in), and the staleness sweep skips it,
because waiting on a human is legitimately quiet. Quiet, but not unwatched
(#52, both surfaces): a flag set with no escalation comment from its setter
is called out by the sweep — comment-only, scoped to the labeled event, the
label never removed — and a ruling with no real activity for 7 days draws a
comment-only nudge addressed to the decider, linking the escalation. The
nudge carries no marker on purpose: the comment is itself activity, so it
resets its own window and never repeats within a quiet week. Label churn is
never activity, or the sweep would reset itself — and each surface's clock
reads what exists on it: on a pull request, comments, reviews and commits;
on an issue, comments alone. An assignment is the claim clock's fact, not
the ruling's — claiming a flagged issue does not answer it, and buys the
escalation no quiet (#284).
`offsite` is issue-only and records that a claimed issue's deliverable lives
in another repository, where a closing reference cannot make a local open PR
visible to the sweep (#68). The builder sets it in the same step that posts
the cross-repo draft link, then clears it at handoff in the same comment that
reports whether that PR merged or closed. The machine reads the flag and
never writes it. It stops only the claim-reclaim clock: missing assignees are
still flagged, queue-label conflicts and missing queue state are still
repaired, and epic-completion and PR-side stale behavior are unchanged. The
sweep tells the assignee once when every visible cross-referenced PR has
closed; it only tells, and never clears the flag or changes the claim.
`attention` is issue-only and says a demand is parked on an issue for its
assignee. Anyone who needs that assignee's hands — triage, the operator, or a
sibling agent — sets it. The assignee alone clears it, as the first act of
pickup together with a short comment; that removal is the acknowledgement
and re-arms the flag for the next demand. If the session dies before the ack,
the still-visible flag launches the next pickup instead. An unanswered flag
is auditable evidence on the board.
The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
with `needs-ruling`, and never substitutes for queue state. It pauses no
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
unanswered `attention` is exactly the silence the 48-hour reclaim should
take. It is hand-set: the machine never sets `attention`, never assigns
anyone to receive one, and never decides that one has been answered — the
assignee's removal is the only ack. It writes the label in exactly one
place, the derived `claimed``post-merge` transition below, and nowhere
else; where it reads the flag it reads it to diagnose. The PR sweep comments
when `attention` is put on a pull request, and the issue sweep comments when
it is put on an issue with no assignee. Both diagnoses leave the label and
assignees alone; the machine never infers the claim issue, decides that the
demand was answered, or repairs either malformed shape.
An `attention` issue without an assignee is therefore a board bug, not a
demand; anyone may assign it or remove the flag. It never composes with
`post-merge`, whose released claim has no assignee to answer the demand. The
one machine-clear exception is the derived `claimed``post-merge`
transition: releasing the assignee clears a carried `attention` in the same
edit. A hand-created `post-merge` + `attention` composition is flagged, not
rewritten.
The three signals are mutually distinct: `attention` means an assignee owes
a move; `needs-ruling` means a human owes a decision under
[the escalation contract and ladder](BUILDER.md#the-ruling-ask); and a bare
`@`-mention is an FYI that demands nothing and remains perfectly fine. A
demand that is itself a human decision carries `needs-ruling`, never both.
This distinction records the
[#16 missed-ruling incident](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
and why the rejected mention poll is not returning: ordinary thread traffic
re-arms mentions, but only the writer can declare that a move is owed (#83).
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
set is per-repo: PRs get theirs from changed paths via the labels workflow's
scope job — an additive write only, so a label applied by hand or by an agent
while the machine runs always survives it (#130) — and issues get theirs from
triage. This file never enumerates a set — it is mirrored
byte-identically into every governed repo, and any list it carried would be
true in one repo and false in the rest (#104). The set for the repo you are
standing in lives in the two places that are true wherever you read them: its
`.github/labels.conf` (the definitions, one `name|color|description` row per
scope) and its own `CONTRIBUTING.md`, beside the other repo-specific facts.
## Issue types
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
on a PR would say the same thing twice and drift.
## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on subscribed
events plus a consumer-owned scheduled discovery sweep. Hourly is the
recommended default when no other engine drives board state; relax it only as
the transition classes with no other writer shrink. Manual dispatch both
bootstraps this taxonomy idempotently and runs the operator's on-demand
full-board reconcile. The sweep warns when the core taxonomy declares a label
the repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.

14
.ceremony/README.md Normal file
View file

@ -0,0 +1,14 @@
# .ceremony/ — the vendored doctrine mirror
Never edit these files ad hoc. The six manifest-listed doctrine files are
byte-identical copies of
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony) at 0.6.3, but
stoke does not run `docs-sync` or re-diff the mirror in CI. Change doctrine
upstream through its own flow, then re-vendor it here when the pin moves.
The labels doctrine is vendored manually from
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony) at 0.6.3 because
stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
`docs-sync --fix` cannot run until or unless a future issue adopts the
ceremony release-workflow pin; until then, doctrine updates must re-vendor
the pinned manifest manually.

219
.ceremony/RELEASES.md Normal file
View file

@ -0,0 +1,219 @@
# Release management
This file describes the release-management pattern available to governed
repositories. Adoption is per repository and operator-ruled: a repository
without version epics is not out of compliance. A repo-local roadmap is the
map; each epic remains the source of truth for its own release. Where an older
repo-local description differs from this file, this file governs.
## The ladder
Represent each planned release with one version epic. The epic is the working
surface for that release: it states the goal, names the members, and records
the ordered waves as checklists. Keep the machine-readable progress checklist
under a heading matching `## Task list`, case-insensitively; the issue-flow
sweep reads task rows there until the next heading when it decides whether to
nudge triage about a completed epic. Other member or wave headings are not
completion inputs.
Keep a short repo-local roadmap beside the epics. The roadmap shows the whole
ladder and points to each working surface; it does not duplicate the live
member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338)
maps the ladder whose `0.1.2` working surface moved from the crufty ledger
[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346).
## Gates
Each version epic declares `Blocked by <predecessor>`. Special ordering — a
double gate or an out-of-chain gate — is written explicitly on that epic;
there is no hidden global schedule. The epic carries `epic` and the
repository's release label, with no queue label. Its `Blocked by` line is a
declaration a human reads: shipping closes the predecessor, then triage opens
the next window by hand as the first step of release-init. The issue-flow
sweep does not promote version epics; automating that gate would require a
separately specified change to its queue-category model.
The gate orders windows, not their contents. Members enter a release only by
decision during release-init. The double gate on
[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
are worked examples of exceptions declared where they apply.
## The membership record
A release issue's `Blocked by` line answers the predecessor gate above and
nothing else. Which issues are *in* the release is a separate record on the
same issue, and the sweep reads it by heading (#343):
- the heading is literally `## Members`, matched case-insensitively, tolerant
of any run of whitespace between the `##` and the word and of trailing
whitespace after it, and the record runs to the next heading — the same
shape `## Task list` already has;
- one member per list row, under any Markdown list marker and only those:
`-`, `*`, `+`, and 1 to 9 digits followed by `.` or `)` all open a row,
because a row is whatever a reader sees as one — and a tenth digit opens
nothing, CommonMark's ordered marker being at most nine digits, so
`1234567890. #412` is narration and enrols no member. Indentation is bounded
the same way: up to three spaces still open a row, four or more open nothing,
a leading tab counting as four. The record is **flat** — one member per
top-level row — and past that bound a line is not one: standing alone it is
an indented code block, and under a row it is a sub-bullet annotating that
member, and neither is a member itself. Below the bound it enrols, an
indented row being the same bytes as a top-level one. The member is the
row's first token after the list marker and an optional checkbox, and it is
a bare local `#<number>`: `- #253` and `- [ ] #253` both enrol #253.
Everything after that token is prose and contributes nothing, so a row is
free to cite the PR that closed it, a sibling repository, or an issue it
names as explicitly *not* a member;
- a row whose first token is anything else — a qualified `repo#N`, a number
with punctuation attached, or ordinary prose — contributes no member. The
parse stays silent rather than guessing;
- a qualified reference is never a member: a window is one repository's DAG,
decided against one board read;
- a row naming the release issue itself contributes no member. The sink is
never one of its own members;
- **there is no fallback to the gate.** A release issue with no members
section enumerates no membership, is not a standing window, and draws no
window flag. A repository whose epics predate this record gets silence,
never a false flag, until its next release-init writes one.
Why a heading and not a marker phrase: the `Blocked by` parse unions every
occurrence of its marker and runs each clause to a sentence terminator, which
is the right error direction for a `blocked` issue and the wrong one for a
release body that is mostly narration *about* its members. Why the first token
and not every reference in the row: a real member row cites merged PRs, other
repositories and explicit non-members, and reading the whole row enrols all of
them.
The cost is named rather than hidden: a version epic maintains two lists — the
`## Members` record and the `## Task list` progress view — and triage writes
both in the same flip. The purchase is that the progress view stays a progress
view, prose-rich and free to carry several issues in one row or to omit a
member that is not in the build queue, while membership is a machine record
with exactly one shape.
## Release-init
The predecessor closing and clearing the next epic's declared gate is the
trigger, and today triage must notice it and open that window by hand.
[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253)
tracks the not-yet-shipped sweep announcement of that duty; do not treat the
announcement as present until the consumer's pin carries it. Triage runs five
steps:
1. Mint the epic's “to mint when this arc opens” list together with findings,
deferred work, and discussion outcomes accumulated since the epic was
written. Each member initially declares `Blocked by <the epic>`.
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
3. Write the waves into the epic body as checklists in claim order, with a
separate verification lane and the progress view under `## Task list`, and
write the window's membership under `## Members` — release-init is where
that record is first written, and until it exists no window stands.
4. Ask the operator to bless the order, then have triage open the first wave
by applying the flip mechanics below. The operator's blessing is the one
step this chain never automates.
5. Ship through the repository's cut process, close the epic, and treat that
close as the trigger for the next window.
heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges
and shared-file contention visible before builders entered the queue. If init
finds no work worth minting, the operator either folds the empty window into a
later release or skips the version, recording that ruling on the epic before
closing it unshipped.
## One primary window, declared parallel tracks
Run one primary release window by default. A cut takes whatever has landed, so
interleaving unrelated windows blurs both the release story and the evidence
behind it. Gates open windows; they do not silently admit members, so builders
still see one deliberately ordered queue.
While a window stands — an open release-labeled issue whose membership record
holds at least one open member — its members form a DAG whose sink is the
release issue. Every member reaches that sink. Members declare only their
immediate predecessors; ordering edges live on members, while the sink records
membership only, in the record above and nowhere else; and the `ready` set is
exactly the graph's current sources. Every close
releases exactly its declared successors, and that whole set is concurrently
claimable: a member may have multiple successors, while the collision rule
already orders any that share a deliverable. Insertion re-points downstream
edges rather than merely appending membership at the sink. It follows that
every `ready` issue is a member. `epic` and `post-merge` issues are exempt
because neither is claimable (#292).
A member that lands `post-merge` releases nothing: that exemption is about
claimability, while a `post-merge` issue is still open and an open predecessor
holds its successors, so every successor declaring on it stays held and the
window stops advancing along that edge (#329).
**When a member reaches `post-merge` and any open declaration names it, triage
splits the remainder**: mint a fresh issue carrying the outstanding criteria
verbatim, naming its owner and its wake condition and citing the original, then
close the original on what it delivered. Triage owns this because only triage
mints work issues and `post-merge` is its completion queue (#329).
**The release edge is the original's close, never the remainder's.** Each
successor's declaration names the original's number, so closing the new issue
releases nothing (#329).
**Split only when an open declaration names the issue.** The trigger is a check
rather than a judgement — run the blocker parse over every open `blocked` body
and see whether this number appears — because an issue that strands nothing is
`post-merge` working as intended (#329).
**Never close work out from under a builder.** Where the original is assigned,
`claimed`, or carrying an open PR, amend its body to hand the outstanding
criteria to the new issue and let its holder close it, so the release edge above
is reached without taking the work from them (#329).
**Do not instead teach the blocker parse that `post-merge` counts as landed.**
That promotes a successor while its predecessor still owes acceptance criteria,
inverts a parser whose deliberate error direction is to hold or flag a reference
it cannot read rather than release it, and needs label data a reference-state
lookup does not carry (#329).
The operator may declare a parallel track at init when its footprint is
disjoint from the primary window: another repository, another artifact, or
provably non-overlapping clusters. The declaration names the boundary and any
bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
is the worked example: its app and artifact form a parallel track while its
small crew-side bridge remains in the primary window.
## Flip mechanics
To admit a member, delete or rewrite its literal, parseable
`Blocked by <the epic>` declaration and swap `blocked` to `ready` in the same
edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads
the raw marker text and still returns the reference. Never preserve history by
negating the marker phrase — the parser unions declarations even when prose
says they no longer apply. Preserve the history only after rewriting the
marker into non-parseable prose, then verify that the parser returns an empty
set for the release gate.
**The same flip adds the member's row to the release issue's membership
record.** That write is not bookkeeping to catch up on later: the record is
the only thing that makes the window stand, so a member flipped `ready`
without a row is, to the sweep, an unblocked non-member — the exact state the
window flag exists to report. Verify the flip by reading the record back and
finding the new member's row in it (#343).
Release membership is a decision, never a sweep default. Triage performs each
flip only after the operator blesses the wave; the issue-flow sweep may resolve
ordinary issue dependencies, but it does not choose a release's contents.
heavy-duty/crew#346 records the member-by-member flip that opened its first
wave.
## The ledger pattern
When a release epic has become too crufty to remain a clear working surface,
create a replacement and treat the old epic as a ledger. Do not close the old
epic until every live member declaration points at the replacement and the
blocker parser verifies the new set. Closing early can release every member
that still names the old issue.
The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346)
transition is the worked example: all member declarations were re-pointed and
parse-verified before #162 closed; #162 remains the historical record while
#346 is the release's working surface.

164
.ceremony/REVIEWER.md Normal file
View file

@ -0,0 +1,164 @@
# REVIEWER.md — the reviewer role
You are one voice on a panel. The panel's job is to converge — on an
approval the human can trust, or on a precise statement of what is wrong.
The machine reads only your **verdict**; humans read your reasons.
## The verdict doctrine
- **Every review ends in a verdict**: approve, or request changes. A
comment-only review is a non-verdict — it does not say whether the round
passed, the state machine treats it as not-approved, and the PR simply
stalls. If you have an opinion, you have a verdict; commenting without one
only wedges the flow.
- **The verdict carries blockingness only; the body carries the feedback.**
Non-blocking nits ride an **approval**, and the builder addresses them at
their discretion. Anything blocking — including a question whose answer
gates your approval — is **request changes**, saying exactly what
unblocks it.
- **Name what you could not verify, in the verdict body.** Say which checks
you could not run and why, and what you relied on instead: CI, reading, or
a narrower probe. An unstated environment gap reads as coverage — exactly
the blind spot Kimi's [crew report](https://github.com/heavy-duty/crew/blob/main/kimi-bot-andresmgsl/assessment.md)
identified for boxes without `node` or `shellcheck`.
- An approval you would not defend to the human is a defect. You are not
being asked to be agreeable; you are being asked to be right.
## What you review against
In order of authority:
1. **The issue's acceptance criteria** — the PR's `Closes #N`, its
cross-repo `Part of <owner>/<repo>#N`, or its `Refs #N` when the issue
body marks a criterion post-merge, names your spec. That last shape is
not a defect: the issue directs it, triage owns that close, and a
request-changes on the "missing" keyword enforces the bug the shape
exists to fix — `Closes #137` closed its issue with a post-merge
criterion unmet (#151). For a `Refs #N` body, also verify that no closing
keyword immediately precedes `#N` anywhere in the body, even in prose
explaining the hand close or inside a code span: GitHub used those exact
shapes to close #209, #212 and #199 (#200, #218). The safe forms put the
number first (`#N is closed by hand`) or omit it (`triage closes the issue
by hand`). Check every
criterion; a PR that ships less than the issue says is a request-changes
even if the code is beautiful.
2. **The repo's load-bearing constraints** — the rules bought with
incidents (in ceremony itself: issue #1's constraint list; in a governed
repo: its own CONTRIBUTING plus ceremony's README). A change that
"simplifies away" a constraint gets request-changes with a link to the
incident that made the rule.
- **Verify a pinned consumer at its pin, not ceremony's `main`.** Every
option, trigger, config key, and unmarked documentation claim must exist
at that ref; run the pinned tool against the proposed config or read the
tagged file. On [box#164](https://github.com/heavy-duty/box/pull/164),
`0.1.0`'s `load_config` rejected `triage-actors=...` with
`malformed label row` and `exit=1`. CI green on a conversion PR proves
nothing about the new config: the base branch's workflow is what ran.
- **Third-party actions never hold a write-capable token by default.** In
any job whose token is write-capable (`packages: write`,
`contents: write`, `id-token: write`, or one carrying deploy secrets),
the default is a repo-owned script a test can drive. A third-party
action may hold that token only if it comes from an **established
publisher** — a real organization with maintenance history and more
than one maintainer, not a memberless shell or a lone account shipping
an unauditable `dist/` blob — and is **pinned by full commit SHA**. An
action matching the incubator red-flag profile never holds a write
token, however well it works. Read-only jobs: ordinary dependency
judgement, SHA-pinning still required. This is bot-run infrastructure —
no human watches runtime logs, so a compromised action's window is
unbounded (incubator#53/#54; #216).
3. **The code itself** — correctness first, then tests (does the test plan's
floor exist? do the failure cases actually fail?), then conventions.
Changelog line present for behavior changes; comments carry why, not
what.
**Verify over opine.** Run what can be run; construct the failing input; a
test settles what a comment thread can't. A review that says "I ran X and
saw Y" outranks one that says "this looks like it might".
## Where you review
- **A review request on you is your authorization** in any `heavy-duty` repo
and on any fleet member's fork. You need no separate permission and do not
wait for the repo to appear on a list: review is reversible
read-plus-comment work, and the requester already decided it should happen.
- **A request is authorization, not panel membership.** Convergence is
measured against the target repo's `panel[<author>]=` line if its
`labels.conf` defines one for the PR author, else its `panel=` line; minus
the author in either case (#224). If you
are requested off-panel, post the verdict anyway and say in its body that
it is advisory; neither your silence nor your request-changes is a gate the
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
rig#112 showed why authorization and membership must not be conflated.
- **Being requested is a wake condition of its own.** It is how work in a
repo you have never heard of reaches you; a repo list finds only work in
repos somebody thought to list.
## How you work the queue
- **Your queue is the API, not the search index.** Enumerate
`requested_reviewers` from the pulls API, your reviews from
`pulls/N/reviews`, and comments from `issues/N/comments`. Search lag left
cast#143, incubator#25, and box#164 waiting, as Claude's
[crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/assessment.md)
records: search is only a backstop that adds candidates, never evidence of
no duty. `requested_reviewers` self-clears when you submit, so the endpoint
shows what you owe now.
- **Every write is one-shot, keyed to (you, PR, head SHA).** Put a fresh
read and verify immediately around the mutation; a session-start check is
insufficient. If verification says it landed, stop even when the CLI
looked unhappy. This binds the `🔎` announce as much as the verdict:
deduplicate all discovery paths before acting. Duplicate verdicts on
[#26](https://github.com/heavy-duty/ceremony/pull/26),
[#29](https://github.com/heavy-duty/ceremony/pull/29), and
[#39](https://github.com/heavy-duty/ceremony/pull/39), and duplicate
announces on [#32](https://github.com/heavy-duty/ceremony/pull/32), bought
the rule; do not answer a double-post with a third comment.
- **Review each head in a throwaway checkout; keep the main clone clean.**
Use a detached worktree per PR head and remove it after the verdict. A
crashed build corrupted Claude's build clone in 2026-07-22
([crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/knowledge.md));
running another tree in the clone you keep risks the whole box.
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a proposal where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the
proposal, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge.
- **Approve a moving target.** Your approval is of a specific head. If the
builder pushes after your approval, GitHub stales it — that is correct,
and the builder owes a re-request, not an assumption.
## The round rhythm
- Review the **whole PR at the current head** each round, not just the diff
since your last comments — the fix for someone else's point can break
yours.
- The builder answers rounds whole and re-requests you; until re-requested,
the ball is not yours (`state:addressing` is the builder working — pile-on
reviews mid-address just churn the target).
- A **draft carrying `state:addressing` is a fix round in progress**, not
abandonment: an engine may convert a PR back to draft at round close so the
builder's mid-round saves stop firing CI, and the flip back to ready is the
builder's own act announcing the round is answered
([BUILDER.md](BUILDER.md#the-review-round)).
- Convergence = every panel verdict approves the current head, no
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
the panel's job is done.
- Flag an unowned decision when it belongs to a human: org policy, published
artifacts, secrets, prod, or any choice whose cost lands outside the PR. A
disagreement within the panel is one instance, not the definition
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Argue a
panel disagreement in the PR with evidence until one side concedes or the
builder escalates; two reviewers pulling a builder in opposite directions
without resolution is a panel failure, not a builder failure.
`needs-ruling` is set by the **builder**, never by you: one accountable
flag-setter per PR hands the human one consolidated question. State the
unowned decision precisely enough for the builder to write
[the canonical ruling ask](BUILDER.md#the-ruling-ask), including what
stops and what continues ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50);
[LABELS.md](LABELS.md)).

165
.ceremony/TRIAGE.md Normal file
View file

@ -0,0 +1,165 @@
# TRIAGE.md — the triage role
You are the only door work issues come through. Humans and agents file
**proposals**; you decide what becomes work and set the quality builders and
reviewers receive.
## Why this door exists
Proposals may be ambiguous; work issues may not: a builder must be able to
execute one **without asking anything**. One accountable role keeps builders
from guessing.
## Your inputs
- **Every open proposal** in the repo you serve.
- **Stray issues** — anything filed outside the proposal form by a non-triage
actor. Label it
`needs-triage`, then either bring it up to contract (below) or convert its
substance into a proposal and close it, saying why. Route the work
without shaming the filer.
## For each proposal, converge on exactly one outcome
1. **Answer.** The question has an answer, the bug is not one, the idea is
already shipped or tracked. Link the code, doc, or issue; mark answered.
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
repo, its history, or its docs. Ask the 23 pointed questions whose
answers would let you write the issue — then stop and wait. Do not mint an
issue that carries the ambiguity forward; that just moves your job onto
the builder.
3. **Escalate.** The pending thing is a decision only a human owns — org
policy, published artifacts, secrets, prod, or any choice whose cost lands
outside the work. A panel deadlock is one instance, not the definition
(#50 D11). Say precisely what the decision is, name the decider, and use
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
including its options, recommendation, blocked/continues statement, and
reversible-only default rules (#50 D12D13).
The proposal is where humans decide; wait there. When the decision
blocks something already on the board — an existing issue, or minted work
a proposal's ruling gates — set `needs-ruling` on it too, so the board
shows where the human's turn is; the issue keeps its queue label.
When you direct a builder to hold a claim, say the claim is **parked**,
name what it waits on, and set `attention` so the assignee's ack is visible
on the board — the directive and the builder's doctrine
([BUILDER.md](BUILDER.md#claiming)) must use one word.
Immediately before asserting label-borne state in prose — a hold, a
claim, a queue state, whether in a comment, a body header, or a
`needs-ruling` ask — re-read that issue's **label events**
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
comments: the answer often arrives as a label with no comment, and a
write that re-read only the thread races it (#149, #151).
Past 24 hours from the current episode's `labeled` event, if the ruling
still stands and doubt remains, it is triage's duty to pick the option the
builder proceeds on, record that pick as a decision, and stay accountable
for it; the operator may overturn it at merge (#50 D13D14). You set the
flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
agreement is reached, record the ruling as a decision in one comment,
remove the label, and return the issue to its flow in that same comment;
when that ruling or any directive or answered builder question delivers
the assignee's next move in prose, set `attention` in the same comment on
the assigned issue that owns the claim — never on the pull request, even
when the comment lives there. Flagging an unassigned issue is a board bug,
not a demand; repair the board rather than setting `attention`.
This is not a substitute for minting work or for `needs-ruling`.
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
where it belongs if anywhere, close. A refusal with reasons is a good
outcome; a zombie proposal is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
## The issue contract
Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the proposal it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an
open question, the issue is not ready to exist — go back to outcome 2 or 3.
- **Tasks**: the steps, checkboxed, in order.
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
the builder's definition of done and the reviewer's review spec, verbatim.
A criterion that can only be checked after the merge must carry its own
mechanism, in the criterion itself: that it is post-merge, that triage
owns the close, and that the PR references the issue with `Refs #N`
rather than `Closes #N`; relying on somebody to reopen the issue is an
incomplete criterion (#151). The merge moves the issue to `post-merge` and
releases the claim. The sweep writes the transition comment when it derives
the move; on a hand move, triage writes the comment in the same tick. In
either case triage follows up with the remaining criteria, their owner, and
the wake condition for completion.
- **Test plan**: what proves it, including the cases that must fail.
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
epic organizes it. Name a cross-repo dependency the same way with its
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
cannot resolve it, so triage verifies it and flips the issue by hand.
When a deliverable is already carried by an open `ready`, `claimed`, or
`blocked` issue, the newer issue must declare an unconditional collision
edge with `Blocked by #N`, naming the newest open carrier; there is no
alternative for disjoint regions. This keeps every `ready` issue
concurrently claimable and makes each close release one successor (#288).
During a standing release window, every mint also gets a binary membership
call in the same tick. A non-member names the release issue as its blocker
in its own Dependencies. A member is placed with three writes: the new issue
names its immediate member predecessors; every member whose immediate
predecessor the new issue becomes adds or re-points its dependency to the
new issue, dropping any predecessor the new issue now reaches (inserting X
into A → B makes A → X → B, so B drops A); a member that must land after the
new issue but already reaches it through another member declares nothing
new; and the release issue adds a row for the new issue to its membership
record, which records membership only and is the only place the sweep reads
it — a release issue's `Blocked by` line answers its predecessor gate and
never its membership (#292, #343). Collision and window edges are
independent, so write both when both apply.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
The bar, stated once: **a competent builder who has read only this issue and
the repo can succeed.** The release-ceremony epic and its children
(heavy-duty/ceremony#1#16) are the house exemplars — that is the density
expected.
## Multi-issue work
When an acceptance produces more than one issue, mint an **epic** (`epic`
label) with the approach, decisions, constraints, and a dependency-ordered
child checklist. Children reference the epic; that checklist is the progress
view. For every epic, put it under a heading
literally `## Task list`, matched case-insensitively with nothing but optional
trailing whitespace; any other heading is invisible to the sweep and draws
neither a warning nor a completion nudge (#266). Builders never pick the epic
itself. Keep the checklist current — a stale epic misleads every scan.
Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
## Backlog hygiene
- **Dedup before minting** — search issues *and* closed issues; extend or
reopen before duplicating.
- The issue-flow sweep flips `blocked``ready` when every named dependency
lands, and flags a blocked issue whose dependency declaration is unreadable.
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
+ no activity → comment, unassign, restore `ready`.
- `post-merge` is triage's completion queue, not a parked claim. Tick verified
criteria and close under the criterion's existing contract. If corrective
build work becomes necessary, move it to `ready` or mint a fresh `ready`
issue: any builder claims from current `main`, the original builder has no
special standing, and re-entry does not set `attention`.
- Automation never guesses intent. Resolve the conflict comments it leaves on
malformed queue states, and close or extend completed epics when nudged.
- **Close obsolete issues** with the reason and a link to what obsoleted
them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie.
- **A lifted hold makes its body prose stale in the same instant, and the
body is yours.** When a hold lifts, correct the body header that described
it in the same tick — do not leave it to the builder or next reader (#149).
## What you never do
- Write code, review code, or build the thing yourself.
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
- Make the human's decisions (outcome 3 exists for those), or soften a
refusal into a vague issue to avoid saying no.
- Mint a work issue to explore an idea — file a proposal instead.

25
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,25 @@
# CI: run the test suite on every pull request and on pushes to main, so
# regressions are caught before they reach a release tag.
#
# Requirements:
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
# your runner actually advertises (common: docker, ubuntu-latest).
name: ci
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: docker
container:
image: node:22-bookworm
steps:
- name: Check out commit
uses: actions/checkout@v4
- name: Run tests
run: npm ci && npm test && npm run check:governance

View file

@ -0,0 +1,49 @@
name: labels-sweep
on:
# The consumer owns this cadence (#203). Hourly is the recommended default
# when no other engine drives board state: the cron is then the sweep's only
# wake for four transition classes — a review verdict landing (no
# pull_request_review trigger on the labels caller), blocker:ci-red
# set/cleared, blocker:conflict when another PR merges under this one, and
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
# latency on those four while cutting nominal scheduled sweeps from four an
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
# wakes this workflow with bootstrap=no on every board event, so the
# declared input is part of the contract: a dispatch naming an undeclared
# input is refused, and the trigger job goes loudly red.
workflow_dispatch:
inputs:
bootstrap:
description: Bootstrap the label taxonomy before sweeping
type: choice
options: ["yes", "no"]
default: "yes"
permissions:
contents: read
checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
issues: write
pull-requests: write
jobs:
sweep:
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@0.6.3
with:
# Pass the dispatch input through the workflow_call boundary
# explicitly — a called workflow must not rely on reading the caller's
# event inputs (ceremony#215 measured that failing). Empty (schedule)
# maps to "no" explicitly, so a cron-woken sweep never re-upserts the
# taxonomy.
bootstrap: ${{ inputs.bootstrap || 'no' }}
# If this repo's PR-facing labels caller is named anything but `labels`,
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
# trigger) never count toward blocker:ci-red — a red trigger means "fix
# the caller", which no PR edit can do (#208 reads it).

View file

@ -0,0 +1,34 @@
name: labels
on:
pull_request_target:
# Fork PRs; these carry the head/draft/review facts state:* derives from.
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
# synchronize re-derives on every push. review_requested/review_request_removed
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
# blocker:unrequested when the panel is asked.
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block.
issues:
# Narrowed (#199) to the actions carrying a queue-state change the hourly
# cron cannot wait one cadence for: opened → the mint→needs-triage check,
# closed → the blocker-closes→ready self-heal, edited → a body rewrite of the
# `Blocked by #N` declaration the sweep parses, reopened → a closed issue
# re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned —
# validation + the 48h claim clock, caught within one cadence, and
# labeled/unlabeled were the issues-churn source. The handoff wake is
# pull_request_target:labeled, not issues, so this leaves it intact.
types: [opened, closed, edited, reopened]
permissions:
contents: read
checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
issues: write
pull-requests: write
jobs:
labels:
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.6.3
# If the sweep caller below is named anything but labels-sweep.yml,
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
# dogfood does (self-labels-sweep.yml).

View file

@ -5,9 +5,11 @@
# Requirements:
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
# your runner actually advertises (common: docker, ubuntu-latest).
# - A repository/org secret RELEASE_TOKEN: a token with package:write and
# repository:write scopes for an account allowed to publish packages
# under the heavy-duty org.
# - A repository/org secret RELEASE_TOKEN: a token with read:user,
# write:package and write:repository scopes for an account allowed to
# publish packages under the heavy-duty org. read:user is required because
# publish-release.sh runs `stoke auth login`, which validates the token
# against /user.
name: release
on:
@ -37,20 +39,5 @@ jobs:
- name: Create release and attach .deb
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -euo pipefail
DEB=$(ls dist/stoke_*_all.deb)
# Create the release if it does not exist yet, then grab its id.
RELEASE_ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID=$(curl -sf -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
"$API/releases" | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
fi
curl -sf -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$DEB" \
"$API/releases/$RELEASE_ID/assets?name=$(basename "$DEB")" >/dev/null
echo "Attached $(basename "$DEB") to release $TAG"
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: bash scripts/publish-release.sh "${{ github.ref_name }}" "$(node -p "require('./package.json').version")" "$(ls dist/stoke_*_all.deb)" "${{ github.repository_owner }}" stoke

12
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,12 @@
# The new-issue interception (issue #24, decision 1): interception over
# instruction — prose alone has already proven insufficient everywhere else
# in this org. Blank issues stay disabled; the proposal contact link gives
# non-triage filers a reachable route beside triage's work-order form. That
# chooser is native forge behavior, no automation needed.
blank_issues_enabled: false
contact_links:
- name: Ideas, bugs, questions — file a Proposal
url: https://forgejo.heavyduty.builders/heavy-duty/stoke/issues/new?template=proposal.yml
about: >-
Anyone may file a proposal. Triage converts it into a work issue or
refuses it with reasons; only triage mints work issues (TRIAGE.md).

34
.github/ISSUE_TEMPLATE/proposal.yml vendored Normal file
View file

@ -0,0 +1,34 @@
# This intake form applies no labels: queue labels are triage's explicit act
# (LABELS.md), and the issue-flow sweep catches non-triage authors, so the form
# must not pre-judge the proposal's queue state (#24 D2).
name: Proposal (anyone)
description: >-
Share an idea, bug, question, or rough "we should…" for triage to convert
into work or refuse with reasons.
body:
- type: markdown
attributes:
value: >-
Proposals are the low-bar intake door. Say what you noticed and why it
might matter; triage will decide whether it becomes a work issue.
- type: textarea
id: noticed
attributes:
label: What did you notice?
description: A rough idea, bug, question, or "we should…" is enough.
validations:
required: true
- type: textarea
id: why-it-matters
attributes:
label: Why might it matter?
description: Optional — describe the impact or opportunity if you can.
validations:
required: false
- type: textarea
id: known-context
attributes:
label: What do you already know?
description: Optional — add links, examples, constraints, or prior art.
validations:
required: false

75
.github/ISSUE_TEMPLATE/work-order.yml vendored Normal file
View file

@ -0,0 +1,75 @@
# The issue contract (TRIAGE.md "The issue contract") as a form, so every
# minted issue lands with the same bones and a later parser (#18) can key on
# stable section headings. Consistency, not a gate: the form applies no
# labels — queue labels are triage's explicit act (LABELS.md), and #18's
# sweep is what catches non-triage authors, so the form must not pre-judge
# that (issue #24, decision 2).
name: Work order (triage only)
description: >-
The issue contract (TRIAGE.md) as a form. Only triage mints work issues —
everyone else files a proposal. Triage may still compose by hand when
the form fights it: `stoke issue create --body-file` bypasses forms and stays
legitimate for the triage identity.
body:
- type: markdown
attributes:
value: >-
**The title names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling". The
bar for everything below, stated once: a competent builder who has
read only this issue and the repo can succeed (TRIAGE.md). Labels
(type, `scope:*`, `ready`/`blocked`) are your explicit act after
minting — this form applies none.
- type: textarea
id: context
attributes:
label: Context
description: >-
Why this exists, with links — the proposal it came from, the code
it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
validations:
required: true
- type: textarea
id: spec
attributes:
label: Spec
description: >-
Decisions made, not options listed. If the spec still has an open
question, the issue is not ready to exist.
validations:
required: true
- type: textarea
id: tasks
attributes:
label: Tasks
description: The steps, checkboxed, in order.
placeholder: "- [ ] the first step"
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance criteria
description: >-
Checkboxed, verifiable, and honest — these become the builder's
definition of done and the reviewer's review spec, verbatim.
placeholder: "- [ ] a verifiable outcome"
validations:
required: true
- type: textarea
id: test-plan
attributes:
label: Test plan
description: What proves it, including the cases that must fail.
validations:
required: true
- type: textarea
id: dependencies
attributes:
label: Dependencies
description: >-
`Blocked by #N` / `Blocks #N`, and `Part of #E` when an epic
organizes it — or state "None".
validations:
required: true

16
.github/labeler.yml vendored Normal file
View file

@ -0,0 +1,16 @@
# Path → scope:* map consumed by ceremony's additive labels-scope job.
"scope:cli":
- changed-files:
- any-glob-to-any-file: ["src/**"]
"scope:packaging":
- changed-files:
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml", "package.json", "package-lock.json", "CHANGELOG.md", "changelog.d/**"]
"scope:manifests":
- changed-files:
- any-glob-to-any-file: ["manifests/**"]
"scope:ci":
- changed-files:
- any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"]
"scope:docs":
- changed-files:
- any-glob-to-any-file: ["*.md", "docs/**"]

7
.github/labels.conf vendored Normal file
View file

@ -0,0 +1,7 @@
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
triage-actors=claude-bot-andresmgsl
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
scope:packaging|C5DEF5|scripts/, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces
scope:manifests|C5DEF5|manifests/ — the fleet repo registry data
scope:ci|C5DEF5|.forgejo/workflows/, .github/, and .ceremony/ — the test, label, and governance gates
scope:docs|C5DEF5|root Markdown and docs/ — the prose contract

7
AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# AGENTS.md — start at .ceremony/
This repository is governed by
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony). Read
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
beside it. Repo specifics (the review panel roster, the scope labels, what
a drill means here, code conventions) live in CONTRIBUTING.md.

42
CHANGELOG.md Normal file
View file

@ -0,0 +1,42 @@
# Changelog
## 1.5.0 — 2026-09-02
### Added
- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23).
- Added guided proposal and triage work-order forms, with the contributor guide linking directly to proposal intake. (#50).
### Changed
- Enforced the exact Forgejo ceremony source and version across governance records and workflow pins. (#36).
- Publish release assets and the matching changelog section through stoke's credential-safe CLI. (#54).
### Fixed
- Keep package-lock metadata aligned with the package version and guard against future drift. (#43).
- Cover every governed repository surface and reject unmapped tracked paths or divergent scope names. (#48).
## 1.4.0 — 2026-08-31
### Added
- Added `repo clone` with branch, depth, remote-name, and destination options; credentials stay in ephemeral Git configuration instead of command arguments, remotes, or repository configuration. (#13, #14).
- Added the stoke brand system and a package file allowlist so published packages include the CLI, README, license, design guide, and vector logo without tests or generated binary assets. (#12, #16).
- Added CI for pull requests and pushes to `main`. (#17).
- Added `issue show`, `issue comment`, raw `--json` output for read commands, and commit-pinned `pr review --commit`. (#20).
- Added repeatable labels to `issue create`, with label validation before issue creation. (#26, #29).
- Added ceremony governance automation, vendored role doctrine, configuration validation, the reviewer panel, and path-based scope labels. (#30, #31).
- Added `repo create --owner` for organization-owned repositories while preserving the authenticated-user default. (#24, #35).
- Added `release create --asset` and `release upload`, including streamed uploads, single-asset renaming, release IDs, attached-file listings, backpressure handling, and partial-failure reporting. (#25, #37).
- Added private Debian registry credentials without exposing them in source URLs. (#1, #38).
### Changed
- `auth login` now requests least-privilege scopes by default; use `--full-scopes` for the former broad set or `--scopes` for an explicit set. This is a behavior change. (#9, #19).
- Bumped the ceremony workflow pin from 0.6.1 to 0.6.3, re-vendored all six doctrine files from that release, and updated the mirror's pin record. (#39).
### Fixed
- `install-apt` now fails clearly when registry metadata is absent instead of continuing into an opaque apt failure. (#18).
- Debian installs preserve signature verification across transient failures, refuse unverifiable registries by default, and allow an HTTPS-only bypass solely through the explicit opt-in for the known sqv failure. (#1, #38).

56
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,56 @@
# Contributing to stoke
Read the role instructions in [`.ceremony/`](.ceremony/) first. This file
contains only facts specific to the stoke repository.
## Review panel
| Identity |
| --- |
| `codex-bot-andresmgsl` |
| `glm-bot-andresmgsl` |
| `claude-bot-andresmgsl` |
| `kimi-bot-andresmgsl` |
The `panel=` line in [`.github/labels.conf`](.github/labels.conf) is
authoritative; this table is its human-readable restatement.
## The `scope:*` set
| Scope | Covers |
| --- | --- |
| `scope:cli` | `src/` — the command surface (`cli.js`, `api.js`, and `config.js`) |
| `scope:packaging` | `scripts/`, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces |
| `scope:manifests` | `manifests/` — fleet repository registry data |
| `scope:ci` | `.forgejo/workflows/`, `.github/`, and `.ceremony/` — the test, label, and governance gates |
| `scope:docs` | root Markdown and `docs/` — the prose contract |
These names and path descriptions restate the scope rows in
[`.github/labels.conf`](.github/labels.conf).
## Drills
Stoke currently practises no drill. The repository has no `drills/` directory;
releases use stoke's own tag-driven
[`.forgejo/workflows/release.yml`](.forgejo/workflows/release.yml), not the
ceremony release flow; and the maintainer-created `blocker:drill-pending` label
(label 252) carries no open item.
If the open ruling in #27 adopts ceremony's release workflow, that work must
update this section. This description of the current route takes no position on
the ruling's alternatives.
## Code conventions
- The package uses CommonJS: `package.json` declares `"type": "commonjs"`, and
files under `src/` use `require()` and `module.exports` rather than ESM.
- Tests use Node's built-in `node:test` module, live in `test/*.test.js`, and run
through `node --test`; there is no separate test framework dependency.
- `package.json` requires Node.js 22.12.0 or newer.
- Shell programs under `scripts/` use the `#!/usr/bin/env bash` shebang.
## Work issue ownership
Only triage mints work issues; anyone may file a
[proposal](https://forgejo.heavyduty.builders/heavy-duty/stoke/issues/new?template=proposal.yml),
which triage converts or refuses.

401
README.md
View file

@ -13,13 +13,38 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman
### With apt (Debian/Ubuntu — recommended)
The package is published to the Debian registry of the forge itself. One-time setup:
The package is published to the public Debian registry of the forge itself.
One-time setup:
```bash
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash
```
or manually. First add the forge's registry as an apt source:
If a private registry or a `FORGE_URL=` override requires authentication,
download the installer and supply a Forgejo login and package-readable token:
```bash
export FORGE_USER=your-forgejo-login
read -rsp 'Forgejo token: ' FORGE_TOKEN && echo && export FORGE_TOKEN
curl -fsSLo /tmp/stoke-install-apt.sh \
https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh
sudo --preserve-env=FORGE_USER,FORGE_TOKEN bash /tmp/stoke-install-apt.sh
unset FORGE_TOKEN
```
The authenticated path keeps credentials out of the source URL in a
root-readable apt auth file. To configure that file manually before adding the
source:
```bash
sudo install -d -m 0755 /etc/apt/auth.conf.d
sudo install -m 0600 /dev/null /etc/apt/auth.conf.d/forgejo-heavy-duty.conf
printf 'machine forgejo.heavyduty.builders\nlogin %s\npassword %s\n' \
"$FORGE_USER" "$FORGE_TOKEN" \
| sudo tee /etc/apt/auth.conf.d/forgejo-heavy-duty.conf >/dev/null
```
Then add the forge's registry as an apt source:
```bash
sudo install -d /etc/apt/keyrings
@ -46,7 +71,28 @@ sudo apt-get update && sudo apt-get install stoke
Upgrades then arrive through regular `apt-get upgrade`. `install-apt.sh` performs all of the above, adding the NodeSource repository only when no already-configured apt source offers a new-enough nodejs.
Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9) currently reject the signature Forgejo generates for its Debian registry (an upstream signing bug). `install-apt.sh` detects this and falls back to a `[trusted=yes]` source — integrity then relies on HTTPS to the forge. The script prefers the signed source, so setups heal automatically once the forge is fixed.
Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9)
may reject signatures produced by affected Forgejo versions. By default,
`install-apt.sh` refuses that signature failure and removes the Forge source;
authentication, network, and all other update failures are also fatal and never
disable verification.
If the installer reports the known `sqv` parsing failure and you deliberately
accept HTTPS-only integrity without OpenPGP verification, opt in on a second
run:
```bash
export STOKE_ALLOW_UNVERIFIED_APT=1
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash
unset STOKE_ALLOW_UNVERIFIED_APT
```
For a private registry, re-run the downloaded installer with
`sudo --preserve-env=FORGE_USER,FORGE_TOKEN,STOKE_ALLOW_UNVERIFIED_APT` instead.
This exact opt-in is the only path in the installer that writes a
`[trusted=yes]` source. The installer prints the security trade-off again when
it takes that path.
As a fallback, each release also has the `.deb` attached for direct install: `sudo dpkg -i stoke_<version>_all.deb`.
@ -80,8 +126,8 @@ Example stored config:
```json
{
"url": "https://forgejo.heavyduty.builders",
"login": "kimi-reviewer-andresmgsl",
"username": "kimi-reviewer-andresmgsl",
"login": "kimi-bot-andresmgsl",
"username": "kimi-bot-andresmgsl",
"email": "andres+4@heavyduty.builders",
"token": "<sha1>",
"tokenId": 42
@ -104,6 +150,8 @@ Example stored config:
## Commands
Read commands (`auth status`, `repo list`, `issue list`, `issue show`, `pr list`, `pr show`, `release list`, `release view`, `label list`, `branch list`, `org repos`, `org team list`, `org team member-list`, `user list`, `user show`) accept a `--json` flag that prints the raw API response, pretty-printed, instead of the human-readable format — useful for scripting.
### Global options
```text
@ -125,6 +173,8 @@ Options:
-t, --token <token> use an existing personal access token instead of generating one
--token-file <path> read an existing personal access token from a file
--token-name <name> name for the generated token
--full-scopes grant full read/write access on all non-admin scopes
--scopes <csv> comma-separated list of scopes for the generated token
```
Interactive example:
@ -137,7 +187,7 @@ stoke auth login
Non-interactive example using environment variables:
```bash
export STOKE_USERNAME='kimi-reviewer-andresmgsl'
export STOKE_USERNAME='kimi-bot-andresmgsl'
export STOKE_PASSWORD='...'
stoke auth login
```
@ -146,7 +196,7 @@ Password file example (avoids shell history and special-character issues):
```bash
chmod 600 /run/secrets/stoke-password
stoke auth login -n kimi-reviewer-andresmgsl --password-file /run/secrets/stoke-password
stoke auth login -n kimi-bot-andresmgsl --password-file /run/secrets/stoke-password
```
Existing token example:
@ -159,9 +209,31 @@ Flow:
1. Calls `GET /api/v1/user` to verify credentials and resolve the canonical `login` name.
2. Calls `POST /api/v1/users/{login}/tokens` to generate a personal access token.
3. Requests the standard non-admin scopes: `read/write` for `activitypub`, `issue`, `misc`, `organization`, `package`, `repository`, and `user`.
3. Requests the default least-privilege scopes (see "Token scopes" below).
4. Writes the token, token id, user details and URL to the config file.
Token scopes:
By default the generated token is least-privilege, covering the common
issue/PR/repository commands:
- `read:issue`, `write:issue` — issues, PR comments/reviews, labels
- `read:repository`, `write:repository` — repositories, branches, releases, collaborators, pull requests
- `read:user``auth status`, `user list`, `user show`
- `read:organization``org repos`, `org team list`, `org team member-list`
Organization administration (`org create`, `org avatar`, `org team create`,
`org team member-add`, `org team member-remove`) and package publishing need
broader access. Pass `--full-scopes` for the previous all-scopes behavior
(`read`/`write` on `activitypub`, `issue`, `misc`, `organization`, `package`,
`repository`, `user`), or `--scopes <csv>` for a custom list:
```bash
stoke auth login --scopes read:issue,write:issue,read:repository
```
The scopes the token was created with are printed after a successful login.
### `stoke auth logout`
Revoke the stored token remotely and delete the local config.
@ -196,13 +268,62 @@ stoke auth status
Calls `GET /api/v1/user` with the stored token.
### `stoke repo clone`
Clone a repository from the configured Forgejo instance using the stored credentials.
```text
Arguments:
[directory] destination directory (default: repository name)
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--branch <branch> checkout this branch instead of the default branch
--depth <depth> create a shallow clone with the given history depth
--origin <name> name for the created remote (default: origin)
```
```bash
stoke repo clone -o heavy-duty -r stoke
stoke repo clone -o heavy-duty -r stoke ~/src/stoke --depth 1
```
The stored token is handed to git ephemerally through environment-based config (`GIT_CONFIG_*`): it never appears in the remote URL, on the command line, or in the cloned repository's `.git/config`. Git's output is streamed directly and its exit status is forwarded, so failures behave exactly like a plain `git clone`.
### `stoke repo sync`
Fast-forward an undiverged imported repository from an upstream Git URL.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--from <upstream-url> upstream Git URL (required)
--branch <branch> branch to synchronize (default: the forge repository's default branch)
--tags also create new upstream tags
--dry-run report branch and tag moves without pushing
```
```bash
stoke repo sync -o heavy-duty -r box \
--from https://github.com/heavy-duty/box.git
stoke repo sync -o heavy-duty -r box \
--from https://github.com/heavy-duty/box.git --tags --dry-run
```
The command fetches both branch tips into an ephemeral bare repository and pushes only when the forge tip is an ancestor of the upstream tip. It refuses a diverged tree with both commit SHAs and never offers a force option. With `--tags`, new upstream tags are created; an existing forge tag that points elsewhere is reported and left untouched, and the command exits non-zero after applying any other safe moves. The stored Forgejo token uses the same environment-only Git authentication as `repo clone` and is never written to an argument, remote, or Git config.
This verb deliberately does not merge diverged trees, configure Forgejo pull-mirrors, or copy releases. Follow ceremony's live `docs/UPSTREAM-SYNC.md` procedure for a diverged tree; import a scheduled read-only repository as a pull-mirror; compose release mirroring from `release create` and `release upload`.
### `stoke repo create`
Create a new repository for the authenticated user.
Create a new repository for the authenticated user or an organization.
```text
Options:
--name <name> repository name (required)
-o, --owner <owner> repository owner (authenticated user or organization)
-d, --description <description> repository description
--private make the repository private
--public make the repository public
@ -216,9 +337,13 @@ Example:
```bash
stoke repo create --name stoke-test --private \
-d "Test repository created via stoke"
stoke repo create -o heavy-duty --name shared-project --private
```
Calls `POST /api/v1/user/repos`.
When `--owner` is omitted or names the authenticated user (case-insensitively),
calls `POST /api/v1/user/repos`. For another owner, calls
`POST /api/v1/orgs/{owner}/repos`; Forgejo returns `403` when the caller cannot
create repositories for that organization.
### `stoke repo list`
@ -347,7 +472,7 @@ Options:
Example used to move the heavy-duty repositories into the `heavy-duty` organization:
```bash
stoke repo transfer -o kimi-reviewer-andresmgsl -r box --to heavy-duty
stoke repo transfer -o kimi-bot-andresmgsl -r box --to heavy-duty
```
Calls `POST /api/v1/repos/{owner}/{repo}/transfer`.
@ -366,7 +491,7 @@ Options:
```
```bash
stoke issue list -o kimi-reviewer-andresmgsl -r box -s all -l 0
stoke issue list -o kimi-bot-andresmgsl -r box -s all -l 0
```
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
@ -383,13 +508,54 @@ Options:
-b, --body <body> issue body (markdown)
--body-file <path> read the issue body from a file
--assignee <username...> assign the issue to one or more users
--label <name...> apply one or more labels by name
```
```bash
stoke issue create -o heavy-duty -r stoke -t "Ship v2" --body-file body.md
stoke issue create -o heavy-duty -r stoke -t "Ship v2" --body-file body.md \
--label ready --label enhancement
```
Calls `POST /api/v1/repos/{owner}/{repo}/issues`.
Requested label names are resolved through the paginated repository label list,
then their numeric IDs are included in `POST /api/v1/repos/{owner}/{repo}/issues`.
An unknown label fails before the issue is created.
### `stoke issue show`
Show details of an issue.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-n, --number <number> issue number (required)
--json print raw JSON instead of human-readable output
```
```bash
stoke issue show -o heavy-duty -r stoke -n 10
```
Calls `GET /api/v1/repos/{owner}/{repo}/issues/{number}`.
### `stoke issue comment`
Add a comment to an issue. Body is required (whitespace-only is rejected). When both `-b` and `--body-file` are set, **`--body-file` wins**.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-n, --number <number> issue number (required)
-b, --body <body> comment body (markdown; required unless --body-file)
--body-file <path> read the comment body from a file (wins over -b)
```
```bash
stoke issue comment -o heavy-duty -r stoke -n 10 -b "Confirmed."
```
Calls `POST /api/v1/repos/{owner}/{repo}/issues/{number}/comments`.
### `stoke pr list`
@ -404,7 +570,7 @@ Options:
```
```bash
stoke pr list -o kimi-reviewer-andresmgsl -r box -s all -l 0
stoke pr list -o kimi-bot-andresmgsl -r box -s all -l 0
```
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
@ -500,14 +666,180 @@ Options:
--event <event> approve|approved, request-changes|request_changes, comment (required)
-b, --body <body> review body (markdown; required for request-changes and comment)
--body-file <path> read the review body from a file (wins over -b)
--commit <sha> commit SHA the review applies to (sent as commit_id)
```
```bash
stoke pr review -o heavy-duty -r stoke -n 3 --event approve -b "Ship it."
stoke pr review -o heavy-duty -r stoke -n 3 --event request-changes --body-file notes.md
stoke pr review -o heavy-duty -r stoke -n 3 --event approve --commit 9fceb02
```
Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. Prints the review URL when the forge returns one.
Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. `--commit` is sent as `commit_id`; when omitted, no `commit_id` is sent. Prints the review URL when the forge returns one.
### `stoke release list`
List releases in a repository.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-l, --limit <number> maximum releases to display (default: 50; use 0 for all)
```
```bash
stoke release list -o heavy-duty -r stoke
```
Calls `GET /api/v1/repos/{owner}/{repo}/releases` and auto-paginates.
### `stoke release view`
Show the release for a tag, including its notes and attached assets. Each asset
line includes its filename, size in bytes and download URL.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--tag <tag> tag name of the release (required)
```
```bash
stoke release view -o heavy-duty -r stoke --tag v1.2.1
```
Calls `GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}`.
### `stoke release create`
Create a release. If the tag does not exist yet, Forgejo creates it from `--target` (or the repository default branch). When both `-b` and `--body-file` are set, **`--body-file` wins**.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--tag <tag> tag name for the release (required)
--target <ref> branch or commit the tag is created from (default: default branch)
-t, --title <title> release title (default: the tag name)
-b, --body <body> release notes (markdown)
--body-file <path> read the release notes from a file (wins over -b)
--asset <path> attach an asset (repeatable)
--asset-name <name> override the uploaded filename (exactly one asset)
--draft create as a draft release
--prerelease mark as a prerelease
```
```bash
stoke release create -o heavy-duty -r stoke --tag v1.3.0 \
--body-file release-notes.md --asset dist/stoke_1.3.0_all.deb
```
The command prints the numeric release id, tag and URL. It calls
`POST /api/v1/repos/{owner}/{repo}/releases`, then uploads each asset. If an
upload fails, the release and any assets that already landed are kept; every
asset is attempted, the command names successes and failures, and exits
non-zero.
### `stoke release upload`
Attach one or more assets to an existing release. `--asset-name` overrides the
uploaded filename and is valid only when exactly one `--asset` is supplied.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--tag <tag> tag name of the existing release (required)
--asset <path> asset to upload (required, repeatable)
--asset-name <name> override the uploaded filename (exactly one asset)
```
```bash
stoke release upload -o heavy-duty -r stoke --tag v1.3.0 \
--asset dist/checksums.txt --asset dist/stoke_1.3.0_all.deb
```
The command resolves the tag once with
`GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}`, then uploads each file
to the release's numeric-id asset endpoint. It attempts every asset and exits
non-zero if any upload fails.
### `stoke label list`
List labels in a repository.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-l, --limit <number> maximum labels to display (default: 50; use 0 for all)
```
```bash
stoke label list -o heavy-duty -r stoke
```
Calls `GET /api/v1/repos/{owner}/{repo}/labels` and auto-paginates.
### `stoke label create`
Create a label in a repository. The color is validated (6 hex digits, with or without a leading `#`) before any network call.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--name <name> label name (required)
--color <color> label color, 6 hex digits (required)
-d, --description <description> label description
```
```bash
stoke label create -o heavy-duty -r stoke --name release --color 0E8A16 \
-d "Release flow and version/packaging work"
```
Calls `POST /api/v1/repos/{owner}/{repo}/labels`.
### `stoke label delete`
Delete a label from a repository, by `--id` or `--name` (exactly one is required; passing both is rejected).
```bash
stoke label delete -o heavy-duty -r stoke --name needs-triage
```
Calls `DELETE /api/v1/repos/{owner}/{repo}/labels/{id}`. A `--name` is resolved to an id via the repository label list first.
### `stoke label add`
Add labels to an issue or pull request (PRs are issues as far as labels are concerned).
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-n, --number <number> issue or pull request number (required)
--name <name...> one or more label names (required)
```
```bash
stoke label add -o heavy-duty -r stoke -n 12 --name release scope:cli
```
Calls `POST /api/v1/repos/{owner}/{repo}/issues/{number}/labels`. Names are resolved to ids first; an unknown name fails with `Label not found`.
### `stoke label remove`
Remove labels from an issue or pull request.
```bash
stoke label remove -o heavy-duty -r stoke -n 12 --name needs-triage
```
Calls `DELETE /api/v1/repos/{owner}/{repo}/issues/{number}/labels/{id}` once per label.
### `stoke branch list`
@ -521,7 +853,7 @@ Options:
```
```bash
stoke branch list -o kimi-reviewer-andresmgsl -r box
stoke branch list -o kimi-bot-andresmgsl -r box
```
Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
@ -541,8 +873,8 @@ Options:
Example:
```bash
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u andres --permission admin
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u dan --permission admin
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u andres --permission admin
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u dan --permission admin
```
Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`.
@ -678,7 +1010,7 @@ Options:
```
```bash
stoke org team member-remove --team-id 1 -u kimi-reviewer-andresmgsl
stoke org team member-remove --team-id 1 -u kimi-bot-andresmgsl
```
Calls `DELETE /api/v1/teams/{id}/members/{username}`.
@ -709,6 +1041,33 @@ stoke user show -u andres
Calls `GET /api/v1/users/{username}`.
### `stoke api`
Make an authenticated request to any Forgejo API endpoint and print the JSON response. The escape hatch for everything stoke does not wrap yet — pass the endpoint path without the `/api/v1` prefix.
```text
Arguments:
<endpoint> endpoint path starting with / (required)
Options:
-X, --method <method> GET, POST, PUT, PATCH or DELETE
(default: GET, or POST when --input is given)
--input <json> JSON request body, inline or @path to read from a file
--paginate fetch all pages (GET endpoints returning a JSON array);
overrides any limit/page in the endpoint
```
```bash
stoke api /user
stoke api "/repos/heavy-duty/stoke/pulls?state=closed" --paginate
stoke api /repos/heavy-duty/stoke/issues/12/comments --input '{"body":"hi"}'
stoke api /repos/heavy-duty/stoke/contents/CHANGELOG.md --input @payload.json
```
Calls `{METHOD} /api/v1{endpoint}` with the stored token. The endpoint must start with `/`; the method, `--paginate` + non-GET, `GET` + `--input` (a GET cannot carry a body), and malformed `--input` JSON are all rejected before any network call.
**Security:** `stoke api` is a full authenticated passthrough — it does anything the stored token is allowed to do. Never interpolate untrusted strings (issue titles, PR bodies, user input) into the endpoint or `--input`; treat every call like the credential it carries.
## Architecture
```text
@ -773,7 +1132,7 @@ Release checklist:
## Verification: heavy-duty repository imports
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-bot-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
| Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

28
assets/logo-mark.svg Normal file
View file

@ -0,0 +1,28 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Mark">
<g clip-path="url(#clip0_0_4)">
<rect width="120" height="120" rx="28" fill="#0C0A09"/>
<g id="Ellipse" filter="url(#filter0_f_0_4)">
<circle cx="60" cy="85" r="75" fill="#FF6A2C" fill-opacity="0.35"/>
</g>
<path id="spark" d="M60 22L68.061 51.939L98 60L68.061 68.061L60 98L51.939 68.061L22 60L51.939 51.939L60 22Z" fill="url(#paint0_linear_0_4)"/>
<path id="Star" d="M89 20L91.489 28.511L100 31L91.489 33.489L89 42L86.511 33.489L78 31L86.511 28.511L89 20Z" fill="#FFB347"/>
</g>
<rect x="0.5" y="0.5" width="119" height="119" rx="27.5" stroke="#3A2A1E"/>
</g>
<defs>
<filter id="filter0_f_0_4" x="-55" y="-30" width="230" height="230" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="20" result="effect1_foregroundBlur_0_4"/>
</filter>
<linearGradient id="paint0_linear_0_4" x1="60" y1="22" x2="60" y2="98" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB347"/>
<stop offset="0.5" stop-color="#FF6A2C"/>
<stop offset="1" stop-color="#E2452B"/>
</linearGradient>
<clipPath id="clip0_0_4">
<rect width="120" height="120" rx="28" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

1
changelog.d/57.md Normal file
View file

@ -0,0 +1 @@
- Clarified Debian publish authentication failures with the CI secret source and the local remedies. (#57).

1
changelog.d/60.md Normal file
View file

@ -0,0 +1 @@
- Correct the release workflow's documented token scopes to include the identity check required before publishing. (#60).

1
changelog.d/62.md Normal file
View file

@ -0,0 +1 @@
- Keep Debian registry tokens out of curl process arguments and clean upload credentials and responses on every exit. (#62).

1
changelog.d/63.md Normal file
View file

@ -0,0 +1 @@
- Normalize Debian package payload modes independently of the builder's umask. (#63).

1
changelog.d/64.md Normal file
View file

@ -0,0 +1 @@
- Report supplied tokens that remain active after logout and make unauthenticated status machine-detectable. (#64).

1
changelog.d/65.md Normal file
View file

@ -0,0 +1 @@
- Continue batch imports after one repository cannot resolve its source token, while reporting that item as failed. (#65).

114
docs/DESIGN.md Normal file
View file

@ -0,0 +1,114 @@
# stoke — Brand & Landing Design
> _The operator's hand on the forge._
A complete brand identity and marketing landing page for **stoke**, the CLI for the
Heavy Duty Forgejo forge. Designed from the codebase itself — every command group,
install path, and principle on the page maps to something the tool actually does.
## 🔗 Figma
**[stoke — Brand & Landing (Figma)](https://www.figma.com/design/q6bYh7pRChTbg0diKAVrcS/stoke-Brand-Landing)**
The file has four pages:
| Page | Contents |
| --- | --- |
| `01 · Brand System` | Logo lockup + ember spark mark, 14-token color palette, type scale, voice & principles |
| `02 · Landing — Desktop` | Full 1440px landing: nav, hero, ecosystem strip, command showcase, philosophy split, install, CTA, footer |
| `03 · Landing — Mobile` | 390px responsive landing |
| `04 · Assets & Components` | Command-group icon set (24px line) + generated-asset gallery |
> Renders live in the [Figma file](https://www.figma.com/design/q6bYh7pRChTbg0diKAVrcS/stoke-Brand-Landing) — see "Why the raster assets are not committed here" below.
## Concept
`stoke` is named after the act of feeding and tending a fire. The identity takes that
literally: a working **forge** — controlled heat, hot steel, precision — rendered as
hot ember on near-black anthracite. Industrial craft meets modern dev tool. The product
principle from the README ("every real operation performed against Forgejo becomes a new
CLI command") is the spine of the page.
### Color — ember on anthracite
| Token | Hex | Use |
| --- | --- | --- |
| `bg/base` | `#0C0A09` | Anthracite canvas |
| `bg/raised` | `#1A1614` | Raised surfaces |
| `bg/inset` | `#14100E` | Wells / code blocks |
| `border/subtle` | `#2A2320` | Hairlines |
| `border/ember` | `#3A2A1E` | Warm edges |
| `ember/red` | `#E2452B` | Deep heat |
| `ember/orange` | `#FF6A2C` | **Primary** · CTAs |
| `ember/amber` | `#FFB347` | Bright heat · command text |
| `hot/white` | `#FFF3E6` | White-hot highlight |
| `steel/blue` | `#3B6EA5` | Quench accent · links / URLs |
| `text/primary` | `#F5EDE6` | Primary text |
| `text/secondary` | `#A89A8E` | Secondary text |
| `text/muted` | `#6B5F56` | Meta / muted |
| `text/onEmber` | `#1A0E06` | Text on ember fills |
### Type
- **Archivo** (Black / SemiBold) — geometric-industrial display & headings, tight tracking
- **Inter** (Regular / Medium) — body copy
- **JetBrains Mono** — everything command-flavored (it's a CLI): prompts, flags, install snippets
### Logo
An **ember spark** mark — a four-point spark that reads as both a flame and a striker
feeding the forge — set over a warm glow, locked up with a heavy lowercase `stoke`
wordmark. Vector source in [`assets/logo-mark.svg`](../assets/logo-mark.svg).
## Page structure (desktop)
1. **Nav** — logo, section links, `Get started`
2. **Hero** — forge backdrop, `Every operation becomes a command.`, copy-able `apt-get install stoke`, CTAs
3. **Ecosystem strip** — the six repos the forge manages: `box · rig · cast · infra · handbook · incubator`
4. **Command showcase** — a live, syntax-colored `stoke` terminal session + cards for all 7 command groups (auth, repo, pr, issue, branch, org, user) with real example commands and command counts
5. **Philosophy split** — "Built for the hand on the forge" with the operator image + feature checklist (apt-native, 0600 tokens, batch import, dogfooded)
6. **Install** — apt (recommended) and from-source code cards, with the Node ≥ 22.12 note
7. **CTA band**`Feed the forge.` over ember texture
8. **Footer** — logo, link columns, forge URL, version
## Assets
All imagery generated with **Artlist** (Seedream 5.0 Pro for stills, Kling 1.6 for the
ambient loop) and embedded in the Figma file.
| Asset | Where it lives |
| --- | --- |
| `assets/logo-mark.svg` | **In this repo** — ember spark logo mark, vector, 1.4 kB |
| Hero backdrop · 2048×878 21:9 — glowing coals, hot steel, sparks | Figma, page 04 |
| Operator's hand stoking coals · 3:2 (philosophy section) | Figma, page 04 |
| Molten ember bed · 21:9 (CTA / section backdrops) | Figma, page 04 |
| 5s ambient ember loop (motion) | Figma, page 04 |
| Desktop landing render · brand system render | Figma, pages 02 / 01 |
### Why the raster assets are not committed here
The first version of this change committed all of them — **23 MB of PNG and MP4
into a CLI repo.** That was a mistake, and it was mine. Two concrete costs:
- `stoke`'s `.git` is ~23 MB; the assets would have **doubled it**, permanently.
Git history is forever, so a marketing render committed today is still being
cloned by every contributor in five years.
- `package.json` had no `files` whitelist, so `npm pack` shipped the working
directory. Measured: **the tarball went from ~100 kB to 23.7 MB** — a 200×
bloat delivered to every user of a command-line tool, for images none of them
will ever look at.
Binaries that exist to be *looked at* belong where people look at them: the Figma
file, which is linked at the top and is the source of truth anyway. The vector
logo stays because it is 1.4 kB of text, diffs cleanly, and is the one asset the
project itself might need to render.
This change also adds the missing `files` whitelist to `package.json`, so the
published tarball now contains only `src/` and the docs a user needs — a
pre-existing packaging bug that shipping the test suite had been hiding.
## Notes
- This PR was opened with `stoke pr create` — the tool designing its own storefront.
- Nothing here changes the CLI. It adds a `docs/` design record, one vector
asset, and a packaging fix.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "stoke",
"version": "1.2.1",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stoke",
"version": "1.2.1",
"version": "1.5.0",
"license": "ISC",
"dependencies": {
"commander": "^15.0.0"

View file

@ -1,9 +1,10 @@
{
"name": "stoke",
"version": "1.2.1",
"version": "1.5.0",
"description": "CLI for the heavy-duty forge (Forgejo)",
"main": "src/cli.js",
"scripts": {
"check:governance": "node scripts/check-governance.js",
"test": "node --test",
"start": "node src/cli.js"
},
@ -28,5 +29,12 @@
},
"bin": {
"stoke": "src/cli.js"
}
},
"files": [
"src/",
"README.md",
"LICENSE",
"docs/DESIGN.md",
"assets/logo-mark.svg"
]
}

View file

@ -67,9 +67,9 @@ EOF
# Native package (no Debian revision in the version), so plain changelog.gz.
gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz"
# Normalize permissions regardless of the builder's umask: no group/other
# write anywhere, executable entry point.
chmod -R go-w "$PKG/usr"
# Normalize permissions regardless of the builder's umask: traversable
# directories, readable files, and execute bits retained only where intended.
chmod -R u+rwX,go=rX "$PKG/usr"
chmod 0755 "$LIB/src/cli.js"
# --- control -----------------------------------------------------------------

73
scripts/changelog-section.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Vendored from heavy-duty/ceremony 0.6.3:
# lib/changelog.sh (changelog_section + changelog_section_problem)
# bin/changelog-section
set -euo pipefail
changelog_section() {
awk -v ver="$2" '
/^## / { if (found) exit; found = ($2 == ver); next }
found && !body && /^[[:space:]]*$/ { next }
found { body = 1; print }
' "$1"
}
changelog_section_problem() {
local file="$1" ver="$2" notes problem
if ! awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$file"; then
printf "no section for '%s'\n" "$ver"
return 1
fi
[ "$ver" = "Unreleased" ] && return 0
notes="$(changelog_section "$file" "$ver")"
if ! printf '%s\n' "$notes" | awk '/^[[:space:]]*[-*][[:space:]]/ { found = 1; exit } END { exit !found }'; then
printf "section '%s' has no entries — a heading is not an entry\n" "$ver"
return 1
fi
problem="$(
printf '%s\n' "$notes" | awk '
/^### / {
if (heading != "" && !entry) {
reported = 1
print heading
exit
}
heading = $0
entry = 0
next
}
heading != "" && /^[[:space:]]*[-*][[:space:]]/ { entry = 1 }
END {
if (!reported && heading != "" && !entry) print heading
}
'
)"
if [ -n "$problem" ]; then
printf "section '%s' has an empty heading: '%s'\n" "$ver" "$problem"
return 1
fi
}
ver="${1:-}"
changelog="${2:-CHANGELOG.md}"
if [ -z "$ver" ]; then
echo "usage: changelog-section.sh <version> [<changelog>]" >&2
exit 2
fi
[ -f "$changelog" ] || {
echo "changelog-section: no such file: $changelog" >&2
exit 1
}
if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then
echo "changelog-section: $changelog has no publishable section for '$ver'" >&2
printf 'changelog-section: %s\n' "$diagnosis" >&2
exit 1
fi
notes="$(changelog_section "$changelog" "$ver")"
printf '%s\n' "$notes"

152
scripts/check-governance.js Normal file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const UNSCOPED_PATHS = [
'.gitignore', // Repository plumbing has no product surface.
'assets/logo-mark.svg', // One legacy brand asset does not justify a scope taxonomy.
'test/*.test.js', // Tests inherit the scope of the production surface changed beside them.
];
function parseArgs(argv) {
const options = {
config: path.join(process.cwd(), '.github', 'labels.conf'),
apiUrl: process.env.FORGE_API_URL
|| `${(process.env.FORGE_SERVER_URL || 'https://forgejo.heavyduty.builders').replace(/\/$/, '')}/api/v1`,
};
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag !== '--config' && flag !== '--api-url') {
throw new Error(`unknown option: ${flag}`);
}
const value = argv[index + 1];
if (!value) throw new Error(`${flag} requires a value`);
options[flag === '--config' ? 'config' : 'apiUrl'] = value;
index += 1;
}
return options;
}
function parseConfig(contents, filename) {
let panel;
let triageActors = [];
const scopes = [];
for (const line of contents.split(/\r?\n/)) {
if (!line) continue;
if (line.startsWith('panel=')) {
if (panel) throw new Error(`duplicate panel line in ${filename}`);
panel = line.slice('panel='.length).trim().split(/\s+/).filter(Boolean);
if (panel.length === 0) throw new Error(`panel must name at least one reviewer in ${filename}`);
continue;
}
if (line.startsWith('triage-actors=')) {
triageActors = line.slice('triage-actors='.length).trim().split(/\s+/).filter(Boolean);
continue;
}
const fields = line.split('|');
if (fields.length !== 3 || !fields[0] || !/^[0-9A-Fa-f]{6}$/.test(fields[1]) || !fields[2]) {
throw new Error(`malformed label row: ${line} in ${filename}`);
}
scopes.push(line);
}
if (!panel) throw new Error(`missing panel= line in ${filename}`);
const identities = [...new Set([...panel, ...triageActors])];
for (const login of identities) {
if (!/^[A-Za-z0-9-]+$/.test(login)) {
throw new Error(`malformed login in ${filename}: ${login}`);
}
}
return { identities, scopes };
}
function parseLabeler(contents, filename) {
const mappings = new Map();
let current;
for (const line of contents.split(/\r?\n/)) {
const label = line.match(/^"([^"]+)":$/);
if (label) {
current = label[1];
if (mappings.has(current)) throw new Error(`duplicate scope mapping in ${filename}: ${current}`);
mappings.set(current, []);
continue;
}
const matcher = line.match(/^\s+- any-glob-to-any-file:\s*(\[[^\n]+\])$/);
if (!matcher) continue;
if (!current) throw new Error(`scope matcher has no label in ${filename}`);
let globs;
try {
globs = JSON.parse(matcher[1]);
} catch {
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
}
if (!Array.isArray(globs) || globs.length === 0 || globs.some((glob) => typeof glob !== 'string')) {
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
}
mappings.get(current).push(...globs);
}
for (const [label, globs] of mappings) {
if (globs.length === 0) throw new Error(`scope mapping has no globs in ${filename}: ${label}`);
}
return mappings;
}
function validateScopeNames(scopes, mappings) {
const configNames = new Set(scopes.map((scope) => scope.split('|', 1)[0]));
const labelerNames = new Set(mappings.keys());
const onlyInConfig = [...configNames].filter((name) => !labelerNames.has(name)).sort();
const onlyInLabeler = [...labelerNames].filter((name) => !configNames.has(name)).sort();
const errors = [];
if (onlyInConfig.length > 0) errors.push(`scope names only in labels.conf: ${onlyInConfig.join(', ')}`);
if (onlyInLabeler.length > 0) errors.push(`scope names only in labeler.yml: ${onlyInLabeler.join(', ')}`);
if (errors.length > 0) throw new Error(errors.join('; '));
}
function validateTrackedPaths(mappings) {
let tracked;
try {
tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
} catch (error) {
throw new Error(`cannot enumerate tracked paths: ${error.message}`);
}
const globs = [...mappings.values()].flat();
const uncovered = tracked.filter((filename) => (
!UNSCOPED_PATHS.some((glob) => path.matchesGlob(filename, glob))
&& !globs.some((glob) => path.matchesGlob(filename, glob))
));
if (uncovered.length > 0) {
throw new Error(`tracked paths have no scope mapping: ${uncovered.join(', ')}`);
}
}
async function validateIdentities(apiUrl, identities) {
for (const login of identities) {
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
let response;
try {
response = await fetch(endpoint, { redirect: 'manual' });
} catch (error) {
throw new Error(`${login}: fetch failed: ${error.message}`);
}
if (response.status !== 200) throw new Error(`${login}: HTTP ${response.status} from ${endpoint}`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const contents = fs.readFileSync(options.config, 'utf8');
const { identities, scopes } = parseConfig(contents, options.config);
const labeler = path.join(path.dirname(options.config), 'labeler.yml');
const mappings = parseLabeler(fs.readFileSync(labeler, 'utf8'), labeler);
validateScopeNames(scopes, mappings);
validateTrackedPaths(mappings);
await validateIdentities(options.apiUrl, identities);
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
}
main().catch((error) => {
console.error(`governance: ${error.message}`);
process.exitCode = 1;
});

View file

@ -9,6 +9,7 @@
# Usage:
# ./scripts/install-apt.sh
# FORGE_URL=... OWNER=... ./scripts/install-apt.sh # non-default instance
# STOKE_ALLOW_UNVERIFIED_APT=1 ./scripts/install-apt.sh # explicit HTTPS-only opt-in
#
# Run as root or as a user with sudo.
@ -18,12 +19,27 @@ FORGE_URL="${FORGE_URL:-https://forgejo.heavyduty.builders}"
OWNER="${OWNER:-heavy-duty}"
DISTRIBUTION="${DISTRIBUTION:-stable}"
COMPONENT="${COMPONENT:-main}"
FORGE_USER="${FORGE_USER:-}"
FORGE_TOKEN="${FORGE_TOKEN:-}"
ALLOW_UNVERIFIED="${STOKE_ALLOW_UNVERIFIED_APT:-}"
# Where apt configuration lives; overridable so tests can run against a
# throwaway directory instead of the real /etc/apt.
APT_ETC="${STOKE_APT_ETC:-/etc/apt}"
KEYRING="$APT_ETC/keyrings/forgejo-$OWNER.asc"
LIST="$APT_ETC/sources.list.d/forgejo-$OWNER.list"
AUTH="$APT_ETC/auth.conf.d/forgejo-$OWNER.conf"
if { [ -n "$FORGE_USER" ] && [ -z "$FORGE_TOKEN" ]; } \
|| { [ -z "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; }; then
echo "error: FORGE_USER and FORGE_TOKEN must be set together" >&2
exit 1
fi
if [ -n "$ALLOW_UNVERIFIED" ] && [ "$ALLOW_UNVERIFIED" != "1" ]; then
echo "error: STOKE_ALLOW_UNVERIFIED_APT must be unset or exactly 1" >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
@ -31,6 +47,18 @@ if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
CURL_AUTH=()
if [ -n "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; then
forge_host="${FORGE_URL#*://}"
forge_host="${forge_host%%/*}"
$SUDO install -d -m 0755 "$APT_ETC/auth.conf.d"
$SUDO install -m 0600 /dev/null "$AUTH"
printf 'machine %s\nlogin %s\npassword %s\n' \
"$forge_host" "$FORGE_USER" "$FORGE_TOKEN" \
| $SUDO tee "$AUTH" >/dev/null
CURL_AUTH=(--netrc-file "$AUTH")
fi
update_only_source() {
$SUDO apt-get update \
-o Dir::Etc::sourcelist="$1" \
@ -80,25 +108,59 @@ ensure_nodejs_source() {
}
echo "Adding APT source for $FORGE_URL/$OWNER ..."
$SUDO install -d -m 0755 "$APT_ETC/keyrings"
curl -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null
$SUDO install -d -m 0755 "$APT_ETC/keyrings" "$APT_ETC/sources.list.d"
$SUDO curl "${CURL_AUTH[@]}" -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null
echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
| $SUDO tee "$LIST" >/dev/null
# tee inherits our umask; apt's unprivileged _apt user must be able to
# read these.
$SUDO chmod 0644 "$KEYRING" "$LIST"
# Fail fast with a clear message when the registry has no package published
# yet: without a Release file, `apt-get update` would only fail with a
# generic "repository does not have a Release file" error. A definitive 404
# is fatal; any other curl outcome (e.g. a network hiccup) is left for
# apt-get update to report.
RELEASE_URL="$FORGE_URL/api/packages/$OWNER/debian/dists/$DISTRIBUTION/Release"
if [ "$($SUDO curl "${CURL_AUTH[@]}" -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then
echo "error: no stoke package has been published to the $OWNER Debian registry yet" >&2
echo "($RELEASE_URL returned 404)." >&2
echo "Install stoke via npm or manually instead — see the README." >&2
exit 1
fi
# Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo
# currently produces for its Debian registry (malformed Ed25519 MPI encoding
# in the upstream signing library). Try the properly signed source first so
# this heals automatically once the forge is fixed; otherwise fall back to
# [trusted=yes] — package integrity then relies on HTTPS to our own forge.
if ! update_only_source "$LIST"; then
echo
echo "WARNING: signature verification failed (known Forgejo registry issue" >&2
echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2
echo "security is provided by HTTPS to $FORGE_URL." >&2
echo
# currently produces for its Debian registry. The cause is recorded on #1; it
# is not the key algorithm, because the registry serves an RSA-2048 signing
# key (`gpg --list-packets` on repository.key reports `algo 1` with a
# 2048-bit pkey[0]). Try the properly signed source first so this heals
# automatically once the forge is fixed. Only the exact live sqv failure, plus
# the user's exact opt-in, permits an unverified source; auth, network, and
# other failures must leave verification enabled and retain apt's diagnostic.
if update_output="$(update_only_source "$LIST" 2>&1)"; then
printf '%s\n' "$update_output"
else
update_status=$?
if ! grep -Fqi '/usr/bin/sqv' <<<"$update_output" \
|| ! grep -Fqi 'Malformed MPI' <<<"$update_output"; then
printf '%s\n' "$update_output" >&2
exit "$update_status"
fi
if [ "$ALLOW_UNVERIFIED" != "1" ]; then
$SUDO rm -f "$LIST"
echo "error: apt could not verify the Forgejo registry signature." >&2
echo "On sqv-based apt, the known cause is that sqv-based apt cannot parse" >&2
echo "the Forgejo registry signature, although gpgv-based apt accepts it." >&2
echo "No apt source was left behind." >&2
echo "If you knowingly accept HTTPS-only integrity, re-run with" >&2
echo "STOKE_ALLOW_UNVERIFIED_APT=1 to disable OpenPGP verification." >&2
exit "$update_status"
fi
echo >&2
echo "WARNING: OpenPGP signature verification is disabled for the Forgejo" >&2
echo "registry at $FORGE_URL. You explicitly accepted HTTPS-only integrity" >&2
echo "by setting STOKE_ALLOW_UNVERIFIED_APT=1." >&2
echo >&2
echo "deb [trusted=yes] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
| $SUDO tee "$LIST" >/dev/null
$SUDO chmod 0644 "$LIST"

View file

@ -9,7 +9,7 @@
# component APT component, default: main
#
# Authentication (first match wins):
# 1. STOKE_TOKEN environment variable
# 1. STOKE_TOKEN environment variable (set from secrets.RELEASE_TOKEN in CI)
# 2. The token stored by `stoke auth login`
#
# The Forgejo URL defaults to the instance in the stoke config, falling back
@ -30,13 +30,34 @@ CONFIG_JSON="$(node -e "const c = require('$ROOT/src/config').loadConfig(); if (
TOKEN="${STOKE_TOKEN:-$(node -pe "(JSON.parse(process.argv[1] || '{}').token) || ''" "$CONFIG_JSON")}"
FORGE_URL="${FORGE_URL:-$(node -pe "(JSON.parse(process.argv[1] || '{}').url) || 'https://forgejo.heavyduty.builders'" "$CONFIG_JSON")}"
[ -n "$TOKEN" ] || { echo "error: no token. Set STOKE_TOKEN or run: stoke auth login" >&2; exit 1; }
if [ -z "$TOKEN" ]; then
cat >&2 <<'EOF'
error: no token.
In CI, this step reads STOKE_TOKEN from secrets.RELEASE_TOKEN; an empty value
means the secret is unset or unreadable by this workflow, not that the tool is missing.
Locally: export STOKE_TOKEN, or run `stoke auth login`.
EOF
exit 1
fi
if [ -n "${RUNNER_TEMP:-}" ]; then
TMP="$(mktemp -d "$RUNNER_TEMP/stoke-publish.XXXXXX")"
else
TMP="$(mktemp -d)"
fi
trap 'rm -rf "$TMP"' EXIT
HEADER_FILE="$TMP/authorization-header"
RESPONSE_FILE="$TMP/response"
umask 077
printf 'Authorization: token %s\n' "$TOKEN" >"$HEADER_FILE"
chmod 0600 "$HEADER_FILE"
URL="$FORGE_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
echo "Uploading $(basename "$DEB") to $URL"
STATUS="$(curl -sS -o /tmp/stoke-publish-response.$$ -w '%{http_code}' \
-X PUT -H "Authorization: token $TOKEN" \
STATUS="$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X PUT -H @"$HEADER_FILE" \
--upload-file "$DEB" "$URL")"
case "$STATUS" in
@ -44,9 +65,7 @@ case "$STATUS" in
409) echo "Already published (409): this exact version already exists in the registry." ;;
*)
echo "error: upload failed with HTTP $STATUS" >&2
cat /tmp/stoke-publish-response.$$ >&2 || true
rm -f /tmp/stoke-publish-response.$$
cat "$RESPONSE_FILE" >&2 || true
exit 1
;;
esac
rm -f /tmp/stoke-publish-response.$$

49
scripts/publish-release.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Publish one release asset through stoke, creating the release when needed.
#
# Usage: publish-release.sh <tag> <version> <deb> <owner> <repo>
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TAG="${1:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
VERSION="${2:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
DEB="${3:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
OWNER="${4:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
REPO="${5:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
FORGE_URL="${FORGE_URL:-${GITHUB_SERVER_URL:?GITHUB_SERVER_URL or FORGE_URL is required}}"
RELEASE_TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
[ -f "$DEB" ] || { echo "publish-release: no such asset: $DEB" >&2; exit 1; }
if [ -n "${RUNNER_TEMP:-}" ]; then
TMP="$(mktemp -d "$RUNNER_TEMP/stoke-release.XXXXXX")"
else
TMP="$(mktemp -d)"
fi
trap 'rm -rf "$TMP"' EXIT
TOKEN_FILE="$TMP/token"
CONFIG_FILE="$TMP/config.json"
NOTES_FILE="$TMP/notes.md"
umask 077
printf '%s' "$RELEASE_TOKEN" > "$TOKEN_FILE"
chmod 0600 "$TOKEN_FILE"
run_stoke() {
if [ -n "${STOKE:-}" ]; then
"$STOKE" --config "$CONFIG_FILE" "$@"
else
node "$ROOT/src/cli.js" --config "$CONFIG_FILE" "$@"
fi
}
run_stoke auth login --url "$FORGE_URL" --token-file "$TOKEN_FILE"
"$ROOT/scripts/changelog-section.sh" "$VERSION" CHANGELOG.md > "$NOTES_FILE"
if run_stoke release view --owner "$OWNER" --repo "$REPO" --tag "$TAG" --json >/dev/null 2>&1; then
run_stoke release upload --owner "$OWNER" --repo "$REPO" --tag "$TAG" --asset "$DEB"
else
run_stoke release create --owner "$OWNER" --repo "$REPO" --tag "$TAG" \
--title "$TAG" --body-file "$NOTES_FILE" --asset "$DEB"
fi

View file

@ -12,11 +12,15 @@
*/
const pkg = require('../package.json');
const fs = require('node:fs');
const REQUEST_TIMEOUT_MS = 30000;
// Repository migrations clone the full source repository and can legitimately
// take minutes, so they get a much longer budget.
const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000;
// Release assets can be much larger than JSON API payloads, so uploads get a
// separate budget while retaining the standard timeout for ordinary calls.
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
class ForgejoClient {
constructor(baseUrl, token = null) {
@ -94,6 +98,47 @@ class ForgejoClient {
return data;
}
async uploadRequest(endpoint, form, { timeout = UPLOAD_TIMEOUT_MS } = {}) {
const url = `${this.baseUrl}/api/v1${endpoint}`;
const headers = this.headers();
delete headers['Content-Type'];
let res;
try {
res = await fetch(url, {
method: 'POST',
headers,
body: form,
signal: AbortSignal.timeout(timeout),
});
} catch (err) {
if (err.name === 'TimeoutError') {
throw new Error(`Upload to ${this.baseUrl} timed out after ${timeout / 1000}s`);
}
throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`);
}
const text = await res.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
}
if (!res.ok) {
const msg = data?.message || data?.raw || `HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
get(endpoint) {
return this.request('GET', endpoint);
}
@ -123,9 +168,15 @@ class ForgejoClient {
return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
}
async createRepo(payload) {
async createRepo(payload, owner) {
if (!owner) return this.post('/user/repos', payload);
const authenticatedUser = await this.get('/user');
if (owner.toLowerCase() === authenticatedUser.login.toLowerCase()) {
return this.post('/user/repos', payload);
}
return this.post(`/orgs/${encodeURIComponent(owner)}/repos`, payload);
}
async migrateRepo(payload) {
return this.post('/repos/migrate', payload, { timeout: MIGRATE_TIMEOUT_MS });
@ -134,9 +185,20 @@ class ForgejoClient {
async getAll(endpoint, params = {}) {
const pageSize = 50;
const all = [];
// The pager owns limit/page: a caller-supplied pair must be overridden,
// not duplicated — a duplicated limit pins the page size the server
// honors first and can truncate or loop the walk.
const queryIndex = endpoint.indexOf('?');
const path = queryIndex === -1 ? endpoint : endpoint.slice(0, queryIndex);
const baseQuery = new URLSearchParams(queryIndex === -1 ? '' : endpoint.slice(queryIndex + 1));
baseQuery.delete('limit');
baseQuery.delete('page');
for (let page = 1; page <= 1000; page += 1) {
const query = new URLSearchParams({ ...params, limit: String(pageSize), page: String(page) }).toString();
const items = await this.get(`${endpoint}?${query}`);
const query = new URLSearchParams(baseQuery);
for (const [key, value] of Object.entries(params)) query.set(key, value);
query.set('limit', String(pageSize));
query.set('page', String(page));
const items = await this.get(`${path}?${query.toString()}`);
if (!Array.isArray(items) || items.length === 0) break;
all.push(...items);
if (items.length < pageSize) break;
@ -159,6 +221,14 @@ class ForgejoClient {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload);
}
async getIssue(owner, repo, index) {
return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}`);
}
async createIssueComment(owner, repo, index, body) {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
}
async listPullRequests(owner, repo, opts = {}) {
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts);
}
@ -175,11 +245,13 @@ class ForgejoClient {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
}
async createPullRequestReview(owner, repo, index, event, body) {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, {
async createPullRequestReview(owner, repo, index, event, body, { commitId } = {}) {
const payload = {
event,
body: body || '',
});
};
if (commitId) payload.commit_id = commitId;
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, payload);
}
async mergePullRequest(owner, repo, index, payload) {
@ -190,6 +262,58 @@ class ForgejoClient {
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts);
}
async listReleases(owner, repo, opts = {}) {
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, opts);
}
async getReleaseByTag(owner, repo, tag) {
return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`);
}
async createRelease(owner, repo, payload) {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, payload);
}
async uploadReleaseAsset(owner, repo, releaseId, filePath, name) {
const form = new FormData();
let file;
try {
file = await fs.openAsBlob(filePath);
} catch (err) {
throw new Error(`Could not read asset file ${filePath}: ${err.message}`);
}
form.append('attachment', file, name);
const query = new URLSearchParams({ name });
return this.uploadRequest(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(releaseId)}/assets?${query}`,
form,
);
}
async listLabels(owner, repo, opts = {}) {
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts);
}
async createLabel(owner, repo, payload) {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, payload);
}
async deleteLabel(owner, repo, id) {
return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels/${id}`);
}
// Pull requests are issues as far as labels are concerned, so these two
// serve both surfaces.
async addIssueLabels(owner, repo, index, labelIds) {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels`, {
labels: labelIds,
});
}
async removeIssueLabel(owner, repo, index, labelId) {
return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels/${labelId}`);
}
async addCollaborator(owner, repo, username, permission) {
return this.request('PUT', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`, {
permission,

View file

@ -3,10 +3,12 @@
const { Command, InvalidArgumentError } = require('commander');
const readline = require('node:readline');
const fs = require('node:fs');
const { execSync } = require('node:child_process');
const path = require('node:path');
const { execSync, spawnSync } = require('node:child_process');
const { stdin: input, stdout: output } = require('node:process');
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
const { ForgejoClient } = require('./api');
const { syncRepository } = require('./repo-sync');
const pkg = require('../package.json');
@ -84,12 +86,67 @@ function parseId(value) {
return n;
}
function parseDepth(value) {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
throw new InvalidArgumentError('Depth must be a positive integer.');
}
return n;
}
function collectOption(value, previous) {
return previous.concat(value);
}
function validateAssetOptions(options, { requireAsset = false } = {}) {
if (requireAsset && options.asset.length === 0) {
throw new Error('At least one --asset is required.');
}
if (options.assetName && options.asset.length !== 1) {
throw new Error('--asset-name requires exactly one --asset.');
}
}
async function uploadAssets(client, owner, repo, releaseId, assetPaths, assetName) {
const uploaded = [];
const failed = [];
for (const assetPath of assetPaths) {
const name = assetName || path.basename(assetPath);
try {
await client.uploadReleaseAsset(owner, repo, releaseId, assetPath, name);
uploaded.push(name);
console.log(`Asset uploaded: ${name}`);
} catch (err) {
failed.push({ name, error: err });
console.error(`Asset failed: ${name}: ${err.message}`);
}
}
return { uploaded, failed };
}
// Read commands share a --json flag that prints the raw API response
// (pretty-printed) instead of the human-readable format.
function printJson(data) {
console.log(JSON.stringify(data, null, 2));
}
function makeTokenName() {
const host = require('node:os').hostname() || 'unknown';
return `stoke-${host}-${Date.now()}`;
}
// Least-privilege default: enough for the daily issue/PR/repository commands.
// Org administration (org create/avatar, team create/member-*) and anything
// else outside this set needs --full-scopes or an explicit --scopes list.
const DEFAULT_TOKEN_SCOPES = [
'read:issue', 'write:issue',
'read:repository', 'write:repository',
'read:user',
'read:organization',
];
// The previous behavior: full read/write on every non-admin scope.
const FULL_TOKEN_SCOPES = [
'read:activitypub', 'write:activitypub',
'read:issue', 'write:issue',
'read:misc', 'write:misc',
@ -99,6 +156,10 @@ const DEFAULT_TOKEN_SCOPES = [
'read:user', 'write:user',
];
function parseScopesOption(csv) {
return csv.split(',').map((s) => s.trim()).filter(Boolean);
}
function printErrorAndExit(err) {
console.error(`Authentication failed: ${err.message}`);
if (err.status) {
@ -124,10 +185,27 @@ auth
.option('-t, --token <token>', 'use an existing personal access token instead of generating one')
.option('--token-file <path>', 'read an existing personal access token from a file')
.option('--token-name <name>', 'name for the generated personal access token', makeTokenName())
.option('--full-scopes', 'grant full read/write access on all non-admin scopes', false)
.option('--scopes <csv>', 'comma-separated list of scopes for the generated token')
.action(async (options) => {
try {
let { url, username, password, passwordFile, token, tokenFile, tokenName } = options;
if (options.fullScopes && options.scopes) {
console.error('Use either --full-scopes or --scopes, not both.');
process.exit(1);
}
let scopes = DEFAULT_TOKEN_SCOPES;
if (options.fullScopes) {
scopes = FULL_TOKEN_SCOPES;
} else if (options.scopes) {
scopes = parseScopesOption(options.scopes);
if (!scopes.length) {
console.error('--scopes produced an empty scope list.');
process.exit(1);
}
}
if (tokenFile) token = readSecretFile(tokenFile, 'token');
if (passwordFile) password = readSecretFile(passwordFile, 'password');
@ -159,7 +237,7 @@ auth
const me = await client.verifyBasicAuth(username, password);
const login = me.login;
const tokenRes = await client.createToken(login, password, tokenName, DEFAULT_TOKEN_SCOPES);
const tokenRes = await client.createToken(login, password, tokenName, scopes);
if (!tokenRes.sha1) {
throw new Error('Token generation succeeded but no token value was returned.');
}
@ -173,6 +251,7 @@ auth
tokenId: tokenRes.id,
};
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
console.log(`Scopes: ${scopes.join(', ')}`);
}
saveConfig(config);
@ -216,6 +295,8 @@ auth
} else {
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
}
} else if (!config.tokenId && !options.localOnly) {
console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`);
}
clearConfig();
@ -229,16 +310,25 @@ auth
auth
.command('status')
.description('Show the current authentication status')
.action(async () => {
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
if (!config || !config.token) {
if (options.json) {
console.log('{"authenticated": false}');
} else {
console.log('Not authenticated.');
return;
}
process.exit(1);
}
const client = ForgejoClient.fromConfig(config);
const me = await client.get('/user');
if (options.json) {
printJson(me);
return;
}
console.log('Instance: ', config.url);
console.log('Login: ', me.login);
console.log('Username: ', me.username);
@ -279,11 +369,16 @@ repo
.command('list')
.description('List repositories for the authenticated user')
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const repos = await client.listRepos();
if (options.json) {
printJson(repos);
return;
}
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
if (!display.length) {
console.log('No repositories found.');
@ -302,10 +397,115 @@ repo
}
});
// Hand the stored token to git through environment-based config instead of
// the remote URL or `git clone -c`: GIT_CONFIG_* variables only live for the
// duration of this process, so the token never reaches the command line,
// the remote URL, or the cloned repository's .git/config (which `-c` would
// write into). GIT_TERMINAL_PROMPT=0 keeps git from interactively asking
// for credentials the session already owns.
function gitAuthEnv(config) {
const username = config.username || config.login || 'stoke';
const basic = Buffer.from(`${username}:${config.token}`).toString('base64');
return {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: `http.${config.url}.extraHeader`,
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
};
}
repo
.command('clone')
.description('Clone a repository using the stored Forgejo credentials')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.argument('[directory]', 'destination directory (defaults to the repository name)')
.option('--branch <branch>', 'checkout this branch instead of the default branch')
.option('--depth <depth>', 'create a shallow clone with the given history depth', parseDepth)
.option('--origin <name>', 'name for the created remote', 'origin')
.action((directory, options) => {
try {
const config = loadConfig();
if (!config || !config.url || !config.token) {
throw new Error('Not authenticated. Run: stoke auth login');
}
const base = config.url.replace(/\/+$/, '');
const cloneUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`;
const args = ['clone', '--origin', options.origin];
if (options.branch) args.push('--branch', options.branch);
if (options.depth) args.push('--depth', String(options.depth));
args.push(cloneUrl);
if (directory) args.push(directory);
const res = spawnSync('git', args, {
stdio: 'inherit',
env: gitAuthEnv({ ...config, url: base }),
});
if (res.error) {
throw new Error(`Failed to run git: ${res.error.message}`);
}
if (res.status !== 0) {
// Git's error output already went to stderr; forward its exit status
// so scripts see the same failure a plain `git clone` would produce.
process.exit(res.status == null ? 1 : res.status);
}
console.log(`Cloned ${options.owner}/${options.repo} into ${directory || options.repo}.`);
} catch (err) {
console.error(`Repository clone failed: ${err.message}`);
process.exit(1);
}
});
repo
.command('sync')
.description('Fast-forward an imported repository from its upstream')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('--from <upstream-url>', 'upstream Git URL')
.option('--branch <branch>', 'branch to synchronize')
.option('--tags', 'also create new upstream tags', false)
.option('--dry-run', 'report changes without pushing', false)
.action((options) => {
try {
const config = loadConfig();
if (!config || !config.url || !config.token) {
throw new Error('Not authenticated. Run: stoke auth login');
}
const base = config.url.replace(/\/+$/, '');
const forgeUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`;
const result = syncRepository({
forgeUrl,
upstreamUrl: options.from,
branch: options.branch,
includeTags: options.tags,
dryRun: options.dryRun,
env: gitAuthEnv({ ...config, url: base }),
});
if (result.changed) {
console.log(`${result.branch} ${result.oldSha}..${result.newSha}`);
} else {
console.log(`${result.branch} is up to date at ${result.newSha}`);
}
for (const tag of result.newTags) {
console.log(`tag ${tag.name} create ${tag.sha}`);
}
for (const tag of result.movedTags) {
console.error(`tag ${tag.name} moved upstream: forge ${tag.forgeSha}, upstream ${tag.upstreamSha}; skipped`);
}
if (result.movedTags.length > 0) process.exitCode = 1;
} catch (err) {
console.error(`Repository sync failed: ${err.message}`);
process.exit(1);
}
});
repo
.command('create')
.description('Create a new repository for the authenticated user')
.description('Create a new repository for the authenticated user or an organization')
.requiredOption('--name <name>', 'repository name')
.option('-o, --owner <owner>', 'repository owner (authenticated user or organization)')
.option('-d, --description <description>', 'repository description', '')
.option('--private', 'make the repository private', false)
.option('--public', 'make the repository public')
@ -326,7 +526,7 @@ repo
default_branch: options.defaultBranch,
};
const result = await client.createRepo(payload);
const result = await client.createRepo(payload, options.owner);
console.log(`Repository created: ${result.full_name}`);
console.log(`URL: ${result.html_url}`);
console.log(`Clone (SSH): ${result.ssh_url}`);
@ -455,6 +655,7 @@ repo
continue;
}
try {
const service = item.service || 'github';
const isPrivate = item.public ? false : Boolean(item.private);
const payload = {
@ -478,7 +679,6 @@ repo
if (payload[key] === undefined) delete payload[key];
});
try {
const result = await client.migrateRepo(payload);
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
results.push({ name, status: 'ok', url: result.html_url });
@ -529,6 +729,7 @@ issue
.option('-s, --state <state>', 'issue state: open, closed, all', 'open')
.option('-t, --type <type>', 'issue type filter: issues, pulls', 'issues')
.option('-l, --limit <number>', 'maximum issues to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
@ -537,6 +738,10 @@ issue
state: options.state,
type: options.type,
});
if (options.json) {
printJson(issues);
return;
}
const display = options.limit > 0 ? issues.slice(0, options.limit) : issues;
if (!display.length) {
console.log('No issues found.');
@ -563,6 +768,7 @@ issue
.option('-b, --body <body>', 'issue body (markdown)')
.option('--body-file <path>', 'read the issue body from a file')
.option('--assignee <username...>', 'assign the issue to one or more users')
.option('--label <name...>', 'apply one or more labels by name')
.action(async (options) => {
try {
const config = loadConfig();
@ -574,6 +780,9 @@ issue
if (options.assignee && options.assignee.length) {
payload.assignees = options.assignee;
}
if (options.label && options.label.length) {
payload.labels = await resolveLabelIds(client, options.owner, options.repo, options.label);
}
const result = await client.createIssue(options.owner, options.repo, payload);
console.log(`Issue created: #${result.number} ${result.title}`);
console.log(`URL: ${result.html_url}`);
@ -584,6 +793,64 @@ issue
}
});
issue
.command('show')
.description('Show details of an issue')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'issue number', parseId)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const issueData = await client.getIssue(options.owner, options.repo, options.number);
if (options.json) {
printJson(issueData);
return;
}
const author = issueData.user?.login || '(unknown)';
console.log(`#${issueData.number} [${issueData.state}] ${issueData.title}`);
console.log(`URL: ${issueData.html_url}`);
console.log(`Author: ${author}`);
console.log(`Created: ${issueData.created_at}`);
if (issueData.body) {
console.log('\n' + issueData.body);
}
} catch (err) {
console.error(`Failed to show issue: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
issue
.command('comment')
.description('Add a comment to an issue')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'issue number', parseId)
.option('-b, --body <body>', 'comment body (markdown; required unless --body-file)')
.option('--body-file <path>', 'read the comment body from a file (wins over -b)')
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const rawBody = readBodyOption(options) || '';
if (rawBody.trim().length === 0) {
console.error('Comment body is required. Use -b/--body or --body-file.');
process.exit(1);
}
const result = await client.createIssueComment(options.owner, options.repo, options.number, rawBody);
console.log(`Comment added to #${options.number}.`);
console.log(`URL: ${result.html_url}`);
} catch (err) {
console.error(`Failed to comment on issue: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
const pr = program
.command('pr')
.description('Manage pull requests');
@ -595,6 +862,7 @@ pr
.requiredOption('-r, --repo <repo>', 'repository name')
.option('-s, --state <state>', 'PR state: open, closed, all', 'open')
.option('-l, --limit <number>', 'maximum pull requests to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
@ -602,6 +870,10 @@ pr
const pulls = await client.listPullRequests(options.owner, options.repo, {
state: options.state,
});
if (options.json) {
printJson(pulls);
return;
}
const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls;
if (!display.length) {
console.log('No pull requests found.');
@ -699,11 +971,16 @@ pr
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'pull request number', parseId)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const prData = await client.getPullRequest(options.owner, options.repo, options.number);
if (options.json) {
printJson(prData);
return;
}
const author = prData.user?.login || '(unknown)';
const headRef = prData.head?.ref || '?';
const baseRef = prData.base?.ref || '?';
@ -760,6 +1037,7 @@ pr
.requiredOption('--event <event>', 'review event: approve|approved, request-changes|request_changes, comment')
.option('-b, --body <body>', 'review body (markdown; required for request-changes and comment)')
.option('--body-file <path>', 'read the review body from a file (wins over -b)')
.option('--commit <sha>', 'commit SHA the review applies to (sent as commit_id)')
.action(async (options) => {
try {
const config = loadConfig();
@ -774,7 +1052,7 @@ pr
console.error(`Review event ${options.event} requires a non-empty body. Use -b/--body or --body-file.`);
process.exit(1);
}
const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody);
const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody, { commitId: options.commit });
console.log(`Review submitted on !${options.number}: ${event}.`);
if (result && result.html_url) {
console.log(`URL: ${result.html_url}`);
@ -786,6 +1064,327 @@ pr
}
});
const release = program
.command('release')
.description('Manage releases');
release
.command('list')
.description('List releases in a repository')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.option('-l, --limit <number>', 'maximum releases to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const releases = await client.listReleases(options.owner, options.repo);
if (options.json) {
printJson(releases);
return;
}
const display = options.limit > 0 ? releases.slice(0, options.limit) : releases;
if (!display.length) {
console.log('No releases found.');
return;
}
for (const rel of display) {
const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|');
const tag = flags ? `${rel.tag_name} [${flags}]` : rel.tag_name;
console.log(`${tag} ${rel.name || ''}`.trimEnd());
}
if (releases.length > display.length) {
console.log(`...and ${releases.length - display.length} more (use -l 0 for all).`);
}
} catch (err) {
console.error(`Failed to list releases: ${err.message}`);
process.exit(1);
}
});
release
.command('view')
.description('Show the release for a tag')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('--tag <tag>', 'tag name of the release')
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const rel = await client.getReleaseByTag(options.owner, options.repo, options.tag);
if (options.json) {
printJson(rel);
return;
}
const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|');
console.log(`${rel.tag_name}${flags ? ` [${flags}]` : ''} ${rel.name || ''}`.trimEnd());
console.log(`URL: ${rel.html_url}`);
console.log(`Target: ${rel.target_commitish}`);
console.log(`Author: ${rel.author?.login || '(unknown)'}`);
console.log(`Published: ${rel.published_at}`);
if (rel.assets?.length) {
console.log('\nAssets:');
for (const asset of rel.assets) {
console.log(` ${asset.name} (${asset.size} bytes) ${asset.browser_download_url}`);
}
}
if (rel.body) {
console.log('\n' + rel.body);
}
} catch (err) {
console.error(`Failed to show release: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
release
.command('create')
.description('Create a release (creates the tag too if it does not exist)')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('--tag <tag>', 'tag name for the release')
.option('--target <ref>', 'branch or commit the tag is created from (default: repository default branch)')
.option('-t, --title <title>', 'release title (default: the tag name)')
.option('-b, --body <body>', 'release notes (markdown)')
.option('--body-file <path>', 'read the release notes from a file (wins over -b)')
.option('--asset <path>', 'attach an asset (repeatable)', collectOption, [])
.option('--asset-name <name>', 'override the uploaded filename (exactly one asset)')
.option('--draft', 'create as a draft release', false)
.option('--prerelease', 'mark as a prerelease', false)
.action(async (options) => {
try {
validateAssetOptions(options);
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const payload = {
tag_name: options.tag,
name: options.title || options.tag,
body: readBodyOption(options) || '',
draft: options.draft,
prerelease: options.prerelease,
};
if (options.target) payload.target_commitish = options.target;
const result = await client.createRelease(options.owner, options.repo, payload);
console.log(`Release created: ${result.tag_name} ${result.name || ''}`.trimEnd());
console.log(`Release id: ${result.id}`);
console.log(`URL: ${result.html_url}`);
const uploads = await uploadAssets(
client,
options.owner,
options.repo,
result.id,
options.asset,
options.assetName,
);
if (uploads.failed.length) {
console.error(
`${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed. The release was kept.`,
);
process.exitCode = 1;
}
} catch (err) {
console.error(`Release creation failed: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
release
.command('upload')
.description('Attach assets to an existing release')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('--tag <tag>', 'tag name of the release')
.requiredOption('--asset <path>', 'asset to upload (repeatable)', collectOption, [])
.option('--asset-name <name>', 'override the uploaded filename (exactly one asset)')
.action(async (options) => {
try {
validateAssetOptions(options, { requireAsset: true });
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const releaseResult = await client.getReleaseByTag(options.owner, options.repo, options.tag);
const uploads = await uploadAssets(
client,
options.owner,
options.repo,
releaseResult.id,
options.asset,
options.assetName,
);
if (uploads.failed.length) {
console.error(`${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed.`);
process.exitCode = 1;
}
} catch (err) {
console.error(`Release upload failed: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
function parseColor(value) {
const hex = value.replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
throw new InvalidArgumentError('Color must be 6 hex digits (with or without a leading #).');
}
return hex.toLowerCase();
}
// Label add/remove/lookup go through names on the CLI but ids on the wire,
// so every caller resolves against the repo's label list first.
async function resolveLabelIds(client, owner, repo, names) {
const labels = await client.listLabels(owner, repo);
const byName = new Map(labels.map((l) => [l.name, l.id]));
return names.map((name) => {
const id = byName.get(name);
if (id === undefined) {
throw new Error(`Label not found in ${owner}/${repo}: ${name}`);
}
return id;
});
}
const label = program
.command('label')
.description('Manage repository labels');
label
.command('list')
.description('List labels in a repository')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.option('-l, --limit <number>', 'maximum labels to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const labels = await client.listLabels(options.owner, options.repo);
if (options.json) {
printJson(labels);
return;
}
const display = options.limit > 0 ? labels.slice(0, options.limit) : labels;
if (!display.length) {
console.log('No labels found.');
return;
}
for (const l of display) {
console.log(`#${l.id} ${l.name} #${l.color}${l.description ? `${l.description}` : ''}`);
}
if (labels.length > display.length) {
console.log(`...and ${labels.length - display.length} more (use -l 0 for all).`);
}
} catch (err) {
console.error(`Failed to list labels: ${err.message}`);
process.exit(1);
}
});
label
.command('create')
.description('Create a label in a repository')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('--name <name>', 'label name')
.requiredOption('--color <color>', 'label color, 6 hex digits (with or without #)', parseColor)
.option('-d, --description <description>', 'label description', '')
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const result = await client.createLabel(options.owner, options.repo, {
name: options.name,
color: options.color,
description: options.description,
});
console.log(`Label created: #${result.id} ${result.name} #${result.color}`);
} catch (err) {
console.error(`Label creation failed: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
label
.command('delete')
.description('Delete a label from a repository (by --id or --name)')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.option('--id <id>', 'label id', parseId)
.option('--name <name>', 'label name')
.action(async (options) => {
try {
if (!options.id && !options.name) {
console.error('One of --id or --name is required.');
process.exit(1);
}
if (options.id && options.name) {
console.error('Use either --id or --name, not both.');
process.exit(1);
}
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const ids = options.id
? [options.id]
: await resolveLabelIds(client, options.owner, options.repo, [options.name]);
await client.deleteLabel(options.owner, options.repo, ids[0]);
console.log(`Label deleted: ${options.name || `#${options.id}`} from ${options.owner}/${options.repo}.`);
} catch (err) {
console.error(`Label deletion failed: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
label
.command('add')
.description('Add labels to an issue or pull request')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'issue or pull request number', parseId)
.requiredOption('--name <name...>', 'one or more label names')
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const ids = await resolveLabelIds(client, options.owner, options.repo, options.name);
await client.addIssueLabels(options.owner, options.repo, options.number, ids);
console.log(`Labels added to #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`);
} catch (err) {
console.error(`Failed to add labels: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
label
.command('remove')
.description('Remove labels from an issue or pull request')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'issue or pull request number', parseId)
.requiredOption('--name <name...>', 'one or more label names')
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const ids = await resolveLabelIds(client, options.owner, options.repo, options.name);
for (const id of ids) {
await client.removeIssueLabel(options.owner, options.repo, options.number, id);
}
console.log(`Labels removed from #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`);
} catch (err) {
console.error(`Failed to remove labels: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
const branchCmd = program
.command('branch')
.description('Manage branches');
@ -796,11 +1395,16 @@ branchCmd
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.option('-l, --limit <number>', 'maximum branches to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const branches = await client.listBranches(options.owner, options.repo);
if (options.json) {
printJson(branches);
return;
}
const display = options.limit > 0 ? branches.slice(0, options.limit) : branches;
if (!display.length) {
console.log('No branches found.');
@ -883,11 +1487,16 @@ org
.description('List repositories owned by an organization')
.requiredOption('-o, --org <org>', 'organization name')
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const repos = await client.listOrgRepos(options.org);
if (options.json) {
printJson(repos);
return;
}
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
if (!display.length) {
console.log('No repositories found.');
@ -938,11 +1547,16 @@ team
.command('list')
.description('List teams in an organization')
.requiredOption('-o, --org <org>', 'organization name')
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const teams = await client.listOrgTeams(options.org);
if (options.json) {
printJson(teams);
return;
}
if (!teams.length) {
console.log('No teams found.');
return;
@ -991,11 +1605,16 @@ team
.command('member-list')
.description('List members of a team')
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const members = await client.listTeamMembers(options.teamId);
if (options.json) {
printJson(members);
return;
}
if (!members.length) {
console.log('No members found.');
return;
@ -1055,11 +1674,16 @@ user
.description('Search/list users on the Forgejo instance')
.option('-q, --query <query>', 'search query (empty lists all visible users)', '')
.option('-l, --limit <number>', 'maximum users to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const users = await client.searchUsers(options.query);
if (options.json) {
printJson(users);
return;
}
const display = options.limit > 0 ? users.slice(0, options.limit) : users;
if (!display.length) {
console.log('No users found.');
@ -1082,11 +1706,16 @@ user
.command('show')
.description('Show a single user profile')
.requiredOption('-u, --user <username>', 'username to look up')
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const u = await client.getUser(options.user);
if (options.json) {
printJson(u);
return;
}
console.log(`Login: ${u.login}`);
console.log(`Full name: ${u.full_name || '-'}`);
console.log(`Email: ${u.email || '-'}`);
@ -1098,6 +1727,65 @@ user
}
});
program
.command('api')
.description('Make an authenticated request to any Forgejo API endpoint and print the JSON response')
.argument('<endpoint>', 'endpoint path starting with / (the /api/v1 prefix is added for you)')
.option('-X, --method <method>', 'HTTP method (default: GET, or POST when --input is given)')
.option('--input <json>', 'JSON request body, inline or @path to read it from a file')
.option('--paginate', 'fetch all pages (GET endpoints returning a JSON array)', false)
.action(async (endpoint, options) => {
try {
if (!endpoint.startsWith('/')) {
console.error('Endpoint must start with / (e.g. /repos/owner/repo/pulls?state=closed).');
process.exit(1);
}
const method = (options.method || (options.input ? 'POST' : 'GET')).toUpperCase();
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
console.error(`Unsupported method: ${method}. Use GET, POST, PUT, PATCH or DELETE.`);
process.exit(1);
}
if (options.paginate && method !== 'GET') {
console.error('--paginate only works with GET.');
process.exit(1);
}
if (method === 'GET' && options.input !== undefined) {
console.error('GET requests cannot carry a body. Drop --input, or use -X POST/PUT/PATCH/DELETE.');
process.exit(1);
}
let body = null;
if (options.input !== undefined) {
const raw = options.input.startsWith('@')
? (() => {
try {
return fs.readFileSync(options.input.slice(1), 'utf8');
} catch (err) {
throw new Error(`Could not read input file ${options.input.slice(1)}: ${err.message}`);
}
})()
: options.input;
try {
body = JSON.parse(raw);
} catch (err) {
console.error(`--input is not valid JSON: ${err.message}`);
process.exit(1);
}
}
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const data = options.paginate
? await client.getAll(endpoint)
: await client.request(method, endpoint, body);
console.log(JSON.stringify(data, null, 2));
} catch (err) {
console.error(`API request failed: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
program.parseAsync(process.argv).catch((err) => {
console.error(err);
process.exit(1);

177
src/repo-sync.js Normal file
View file

@ -0,0 +1,177 @@
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
function runGit(args, { cwd, env, accept = [0] }) {
const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' });
if (result.error) throw new Error(`Failed to run git: ${result.error.message}`);
if (!accept.includes(result.status)) {
throw new Error((result.stderr || result.stdout || `git exited ${result.status}`).trim());
}
return result;
}
function remoteTags(url, { cwd, env }) {
const output = runGit(['ls-remote', '--tags', '--refs', url], { cwd, env }).stdout;
const tags = new Map();
for (const line of output.trim().split('\n')) {
if (!line) continue;
const [sha, ref] = line.split(/\s+/, 2);
tags.set(ref.slice('refs/tags/'.length), sha);
}
return tags;
}
function remoteRefSha(url, ref, { cwd, env }) {
const output = runGit(['ls-remote', '--refs', url, ref], { cwd, env }).stdout.trim();
if (!output) return null;
const [sha, foundRef] = output.split(/\s+/, 2);
return foundRef === ref ? sha : null;
}
function divergenceError(branch, forgeSha, upstreamSha) {
return new Error(`Refusing diverged branch ${branch}: forge ${forgeSha}, upstream ${upstreamSha}. Diverged trees are out of scope; follow ceremony docs/UPSTREAM-SYNC.md.`);
}
function syncRepository({
forgeUrl,
upstreamUrl,
branch,
includeTags = false,
dryRun = false,
env,
}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-repo-sync-'));
const forgeRef = 'refs/stoke/forge-branch';
const upstreamRef = 'refs/stoke/upstream-branch';
try {
runGit(['init', '--bare', directory], { cwd: directory, env });
if (!branch) {
const symbolicHead = runGit(['ls-remote', '--symref', forgeUrl, 'HEAD'], {
cwd: directory,
env,
}).stdout;
const match = symbolicHead.match(/^ref:\s+refs\/heads\/(.+)\s+HEAD$/m);
if (!match) throw new Error('Could not resolve the forge repository default branch');
branch = match[1];
}
runGit(['fetch', '--no-tags', forgeUrl, `refs/heads/${branch}:${forgeRef}`], {
cwd: directory,
env,
});
runGit(['fetch', '--no-tags', upstreamUrl, `refs/heads/${branch}:${upstreamRef}`], {
cwd: directory,
env,
});
const oldSha = runGit(['rev-parse', forgeRef], { cwd: directory, env }).stdout.trim();
const newSha = runGit(['rev-parse', upstreamRef], { cwd: directory, env }).stdout.trim();
const ancestry = runGit(['merge-base', '--is-ancestor', oldSha, newSha], {
cwd: directory,
env,
accept: [0, 1],
});
if (ancestry.status !== 0) {
throw divergenceError(branch, oldSha, newSha);
}
const newTags = [];
const movedTags = [];
if (includeTags) {
const discoveredUpstreamTags = remoteTags(upstreamUrl, { cwd: directory, env });
const fetchedUpstreamTags = new Map();
for (const name of discoveredUpstreamTags.keys()) {
const temporaryRef = `refs/stoke/upstream-tags/${name}`;
runGit(['fetch', '--no-tags', upstreamUrl, `refs/tags/${name}:${temporaryRef}`], {
cwd: directory,
env,
});
const upstreamSha = runGit(['rev-parse', temporaryRef], {
cwd: directory,
env,
}).stdout.trim();
fetchedUpstreamTags.set(name, { name, sha: upstreamSha, temporaryRef });
}
const forgeTags = remoteTags(forgeUrl, { cwd: directory, env });
for (const [name, tag] of fetchedUpstreamTags) {
const forgeSha = forgeTags.get(name);
if (!forgeSha) {
newTags.push(tag);
} else if (forgeSha !== tag.sha) {
movedTags.push({ name, forgeSha, upstreamSha: tag.sha });
}
}
}
if (!dryRun && oldSha !== newSha) {
const branchPush = runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], {
cwd: directory,
env,
accept: [0, 1],
});
if (branchPush.status !== 0) {
const currentRef = 'refs/stoke/forge-current';
runGit(['fetch', '--no-tags', forgeUrl, `refs/heads/${branch}:${currentRef}`], {
cwd: directory,
env,
});
const currentSha = runGit(['rev-parse', currentRef], { cwd: directory, env }).stdout.trim();
if (currentSha !== newSha) {
const currentAncestry = runGit(['merge-base', '--is-ancestor', currentSha, newSha], {
cwd: directory,
env,
accept: [0, 1],
});
if (currentAncestry.status !== 0) throw divergenceError(branch, currentSha, newSha);
runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], {
cwd: directory,
env,
});
}
}
}
const reportedNewTags = [];
for (const tag of newTags) {
if (dryRun) {
reportedNewTags.push(tag);
continue;
}
const tagPush = runGit(['push', forgeUrl, `${tag.temporaryRef}:refs/tags/${tag.name}`], {
cwd: directory,
env,
accept: [0, 1],
});
if (tagPush.status === 0) {
reportedNewTags.push(tag);
continue;
}
const forgeSha = remoteRefSha(forgeUrl, `refs/tags/${tag.name}`, {
cwd: directory,
env,
});
if (!forgeSha) {
throw new Error((tagPush.stderr || tagPush.stdout || `git exited ${tagPush.status}`).trim());
}
if (forgeSha !== tag.sha) {
movedTags.push({ name: tag.name, forgeSha, upstreamSha: tag.sha });
}
}
return {
branch,
oldSha,
newSha,
changed: oldSha !== newSha,
newTags: reportedNewTags,
movedTags,
dryRun,
};
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
module.exports = { syncRepository };

View file

@ -1,13 +1,18 @@
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { ForgejoClient } = require('../src/api');
const pkg = require('../package.json');
const realFetch = global.fetch;
const realAbortTimeout = AbortSignal.timeout;
afterEach(() => {
global.fetch = realFetch;
AbortSignal.timeout = realAbortTimeout;
});
function mockFetch(handler) {
@ -66,6 +71,41 @@ test('deleteToken uses Basic auth (Forgejo rejects token auth on token endpoints
assert.match(opts.headers.Authorization, /^Basic /);
});
test('createRepo without an owner keeps the authenticated-user route', async () => {
const calls = mockFetch(() => jsonResponse({ full_name: 'bot/project' }, 201));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createRepo({ name: 'project' });
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://forge.test/api/v1/user/repos');
assert.equal(calls[0].opts.method, 'POST');
});
test('createRepo treats a case-insensitive authenticated owner as the user route', async () => {
const calls = mockFetch((url) => {
if (url.endsWith('/user')) return jsonResponse({ login: 'BuildBot' });
return jsonResponse({ full_name: 'BuildBot/project' }, 201);
});
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createRepo({ name: 'project' }, 'buildbot');
assert.deepEqual(calls.map(({ url }) => url), [
'https://forge.test/api/v1/user',
'https://forge.test/api/v1/user/repos',
]);
});
test('createRepo routes a different owner to the organization endpoint', async () => {
const calls = mockFetch((url) => {
if (url.endsWith('/user')) return jsonResponse({ login: 'buildbot' });
return jsonResponse({ full_name: 'heavy-duty/project' }, 201);
});
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createRepo({ name: 'project' }, 'heavy-duty');
assert.deepEqual(calls.map(({ url }) => url), [
'https://forge.test/api/v1/user',
'https://forge.test/api/v1/orgs/heavy-duty/repos',
]);
});
test('API errors carry message, status and body', async () => {
mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404));
const client = new ForgejoClient('https://forge.test', 'tok');
@ -151,6 +191,24 @@ test('getPullRequest fetches a single pull request', async () => {
assert.equal(calls[0].opts.method, 'GET');
});
test('getIssue fetches a single issue', async () => {
const calls = mockFetch(() => jsonResponse({ number: 7, title: 'Bug' }));
const client = new ForgejoClient('https://forge.test', 'tok');
const issue = await client.getIssue('owner', 'repo', 7);
assert.equal(issue.number, 7);
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7');
assert.equal(calls[0].opts.method, 'GET');
});
test('createIssueComment posts to the issue comments endpoint', async () => {
const calls = mockFetch(() => jsonResponse({ id: 5, html_url: 'https://forge.test/comment/5' }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createIssueComment('owner', 'repo', 7, 'Me too.');
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/comments');
assert.equal(calls[0].opts.method, 'POST');
assert.equal(JSON.parse(calls[0].opts.body).body, 'Me too.');
});
test('createPullRequestComment posts to the issue comments endpoint', async () => {
const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' }));
const client = new ForgejoClient('https://forge.test', 'tok');
@ -179,3 +237,130 @@ test('createPullRequestReview preserves leading and trailing whitespace in the b
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.body, rawBody);
});
test('createPullRequestReview omits commit_id unless a commitId is given', async () => {
const calls = mockFetch(() => jsonResponse({ id: 90 }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '');
assert.ok(!('commit_id' in JSON.parse(calls[0].opts.body)));
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '', { commitId: 'abc123' });
assert.equal(JSON.parse(calls[1].opts.body).commit_id, 'abc123');
});
test('getAll joins pagination with & when the endpoint already has a query', async () => {
const calls = mockFetch(() => jsonResponse([]));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.getAll('/repos/o/r/pulls?state=closed');
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/o/r/pulls?state=closed&limit=50&page=1');
});
test('getAll overrides caller-supplied limit/page instead of duplicating them', async () => {
const calls = mockFetch((url) => {
const page = Number(new URL(url).searchParams.get('page'));
return jsonResponse(page === 1 ? Array.from({ length: 50 }, (_, i) => ({ id: i })) : []);
});
const client = new ForgejoClient('https://forge.test', 'tok');
const all = await client.getAll('/repos/o/r/pulls?state=closed&limit=1&page=9');
assert.equal(all.length, 50);
const first = new URL(calls[0].url).searchParams;
const second = new URL(calls[1].url).searchParams;
assert.deepEqual(first.getAll('limit'), ['50']);
assert.deepEqual(first.getAll('page'), ['1']);
assert.deepEqual(second.getAll('page'), ['2']);
assert.equal(first.get('state'), 'closed');
});
test('release endpoints map to the expected URLs and payloads', async () => {
const calls = mockFetch(() => jsonResponse({ tag_name: '1.0.0' }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.listReleases('owner', 'repo');
await client.getReleaseByTag('owner', 'repo', '1.0.0-rc1');
await client.createRelease('owner', 'repo', { tag_name: '1.0.0', name: '1.0.0', body: '', draft: false, prerelease: false });
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/releases?limit=50&page=1');
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/releases/tags/1.0.0-rc1');
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/releases');
assert.equal(calls[2].opts.method, 'POST');
assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0');
});
test('uploadReleaseAsset streams multipart data without forcing a JSON content type', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-api-'));
const assetPath = path.join(dir, 'artifact.bin');
fs.writeFileSync(assetPath, 'asset bytes');
const calls = mockFetch(() => jsonResponse({ id: 9, name: 'custom name.bin' }, 201));
const client = new ForgejoClient('https://forge.test', 'tok');
try {
await client.uploadReleaseAsset('heavy duty', 'stoke', 42, assetPath, 'custom name.bin');
const { url, opts } = calls[0];
assert.equal(url, 'https://forge.test/api/v1/repos/heavy%20duty/stoke/releases/42/assets?name=custom+name.bin');
assert.equal(opts.method, 'POST');
assert.equal(opts.headers.Authorization, 'token tok');
assert.equal(opts.headers['Content-Type'], undefined);
const attachment = opts.body.get('attachment');
assert.equal(attachment.name, 'custom name.bin');
assert.equal(await attachment.text(), 'asset bytes');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('uploadReleaseAsset identifies a missing local asset path', async () => {
const assetPath = path.join(os.tmpdir(), `stoke-missing-asset-${process.pid}.bin`);
const client = new ForgejoClient('https://forge.test', 'tok');
await assert.rejects(
client.uploadReleaseAsset('owner', 'repo', 42, assetPath, 'artifact.bin'),
(err) => {
assert.match(err.message, /Could not read asset file/);
assert.match(err.message, new RegExp(assetPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
return true;
},
);
});
test('uploadReleaseAsset uses the upload timeout instead of the 30 second JSON timeout', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-timeout-'));
const assetPath = path.join(dir, 'large.bin');
fs.writeFileSync(assetPath, 'content');
let timeout;
AbortSignal.timeout = (milliseconds) => {
timeout = milliseconds;
return new AbortController().signal;
};
mockFetch(() => jsonResponse({ id: 10 }, 201));
const client = new ForgejoClient('https://forge.test', 'tok');
try {
await client.uploadReleaseAsset('owner', 'repo', 42, assetPath, 'large.bin');
assert.equal(timeout, 10 * 60 * 1000);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('label endpoints map to the expected URLs and payloads', async () => {
const calls = mockFetch(() => jsonResponse({ id: 3 }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.listLabels('owner', 'repo');
await client.createLabel('owner', 'repo', { name: 'release', color: '0e8a16', description: '' });
await client.deleteLabel('owner', 'repo', 3);
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/labels?limit=50&page=1');
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/labels');
assert.equal(calls[1].opts.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].opts.body), { name: 'release', color: '0e8a16', description: '' });
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/labels/3');
assert.equal(calls[2].opts.method, 'DELETE');
});
test('issue label add/remove hit the issue labels endpoints', async () => {
const calls = mockFetch(() => jsonResponse(null, 204));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.addIssueLabels('owner', 'repo', 7, [3, 4]);
await client.removeIssueLabel('owner', 'repo', 7, 3);
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels');
assert.equal(calls[0].opts.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].opts.body), { labels: [3, 4] });
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels/3');
assert.equal(calls[1].opts.method, 'DELETE');
});

65
test/build-deb.test.js Normal file
View file

@ -0,0 +1,65 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function copyTree(source, destination) {
fs.cpSync(source, destination, { recursive: true });
}
function buildPackage(umask) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-build-deb-test-'));
const bin = path.join(root, 'bin');
fs.mkdirSync(path.join(root, 'scripts'));
fs.mkdirSync(bin);
fs.copyFileSync(path.join(ROOT, 'scripts', 'build-deb.sh'), path.join(root, 'scripts', 'build-deb.sh'));
copyTree(path.join(ROOT, 'src'), path.join(root, 'src'));
fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(root, 'package.json'));
fs.copyFileSync(path.join(ROOT, 'package-lock.json'), path.join(root, 'package-lock.json'));
const npm = path.join(bin, 'npm');
fs.writeFileSync(npm, '#!/usr/bin/env bash\nexit 0\n');
fs.chmodSync(npm, 0o755);
const result = spawnSync(
'bash',
['-c', 'umask "$1"; exec bash "$2"', 'build-deb-test', umask, path.join(root, 'scripts', 'build-deb.sh')],
{
encoding: 'utf8',
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
},
);
assert.equal(result.status, 0, result.stderr);
const deb = path.join(root, 'dist', 'stoke_1.5.0_all.deb');
const listing = spawnSync('dpkg-deb', ['-c', deb], { encoding: 'utf8' });
assert.equal(listing.status, 0, listing.stderr);
const modes = new Map();
for (const line of listing.stdout.trim().split('\n')) {
const fields = line.trim().split(/\s+/);
const archivePath = fields.find((field) => field.startsWith('./usr/'));
if (archivePath && (fields[0].startsWith('d') || fields[0].startsWith('-'))) {
modes.set(archivePath, fields[0]);
}
}
return { root, modes };
}
test('Debian payload modes are identical under umask 077 and 022', (t) => {
const restrictive = buildPackage('077');
const standard = buildPackage('022');
t.after(() => {
fs.rmSync(restrictive.root, { recursive: true, force: true });
fs.rmSync(standard.root, { recursive: true, force: true });
});
assert.deepEqual(restrictive.modes, standard.modes);
for (const [archivePath, mode] of restrictive.modes) {
assert.equal(mode, archivePath.endsWith('/') ? 'drwxr-xr-x' : archivePath === './usr/lib/stoke/src/cli.js' ? '-rwxr-xr-x' : '-rw-r--r--', archivePath);
}
});

View file

@ -0,0 +1,76 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'changelog-section.sh');
function extract(version, changelog = path.join(ROOT, 'CHANGELOG.md')) {
return spawnSync('bash', [SCRIPT, version, changelog], { encoding: 'utf8' });
}
function withChangelog(contents, assertion) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-changelog-test-'));
try {
const changelog = path.join(dir, 'CHANGELOG.md');
fs.writeFileSync(changelog, contents);
assertion(changelog);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('extracts the complete real 1.4.0 changelog section', () => {
const result = extract('1.4.0');
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.equal(result.stdout.split('\n').length - 1, 21);
assert.match(result.stdout, /^### Added$/m);
assert.match(result.stdout, /^### Changed$/m);
assert.match(result.stdout, /^### Fixed$/m);
});
test('missing version fails with a reason and no stdout', () => {
const result = extract('0.0.0');
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(result.stderr, /no section for '0\.0\.0'/);
});
test('heading without a list entry is rejected as empty', () => {
withChangelog('## 2.0.0\n\n### Changed\n\nProse only.\n', (changelog) => {
const result = extract('2.0.0', changelog);
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(result.stderr, /section '2\.0\.0' has no entries/);
});
});
test('extraction stops before the next version heading', () => {
withChangelog([
'## 2.0.0',
'',
'### Added',
'',
'- Current change.',
'',
'## 1.0.0',
'',
'### Added',
'',
'- Older change.',
'',
].join('\n'), (changelog) => {
const result = extract('2.0.0', changelog);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '### Added\n\n- Current change.\n');
assert.doesNotMatch(result.stdout, /Older change/);
});
});

View file

@ -23,7 +23,7 @@ test('--version matches package.json', () => {
test('--help lists every top-level command', () => {
const out = execFileSync(process.execPath, [CLI, '--help'], { encoding: 'utf8' });
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'branch', 'collaborator', 'org', 'user']) {
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'release', 'label', 'branch', 'collaborator', 'org', 'user', 'api']) {
assert.match(out, new RegExp(`^\\s+${cmd}`, 'm'), `missing command: ${cmd}`);
}
});
@ -40,10 +40,59 @@ test('global --config flag overrides the config location', () => {
// "Not authenticated" instead of silently using the default config.
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
const res = run(['--config', missing, 'auth', 'status']);
assert.equal(res.status, 0);
assert.equal(res.status, 1);
assert.match(res.stdout, /Not authenticated/);
});
test('auth status reports an absent session in text and JSON with a failing status', () => {
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}-auth-status.json`);
const text = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing });
assert.equal(text.status, 1);
assert.equal(text.stdout, 'Not authenticated.\n');
assert.equal(text.stderr, '');
const json = run(['auth', 'status', '--json'], { STOKE_CONFIG_FILE: missing });
assert.equal(json.status, 1);
assert.equal(json.stdout, '{"authenticated": false}\n');
assert.equal(json.stderr, '');
});
test('auth logout identifies a supplied token that remains active without changing local-only output', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-auth-logout-'));
const cfg = path.join(dir, 'config.json');
const config = {
url: 'https://forge.test',
login: 'bot',
username: 'bot',
token: 'token-that-must-not-be-printed',
tokenId: null,
};
try {
fs.writeFileSync(cfg, JSON.stringify(config));
const logout = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg });
assert.equal(logout.status, 0, logout.stderr);
assert.match(logout.stdout, /local credentials/i);
assert.match(logout.stdout, /did not create this token/i);
assert.match(logout.stdout, /cannot revoke it/i);
assert.match(logout.stdout, /still valid on https:\/\/forge\.test/i);
assert.match(logout.stdout, /Settings > Applications/);
assert.doesNotMatch(logout.stdout, /Revoked token/);
assert.doesNotMatch(logout.stdout, /Password for/);
assert.doesNotMatch(logout.stdout, /token-that-must-not-be-printed/);
assert.equal(fs.existsSync(cfg), false);
fs.writeFileSync(cfg, JSON.stringify(config));
const localOnly = run(['auth', 'logout', '--local-only'], { STOKE_CONFIG_FILE: cfg });
assert.equal(localOnly.status, 0, localOnly.stderr);
assert.equal(localOnly.stdout, 'Local credentials removed.\n');
assert.equal(fs.existsSync(cfg), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('invalid --limit is rejected before any network call', () => {
const res = run(['repo', 'list', '-l', 'abc']);
assert.equal(res.status, 1);
@ -56,6 +105,307 @@ test('invalid --team-id is rejected before any network call', () => {
assert.match(res.stderr, /Id must be a positive integer/);
});
test('repo create help lists the owner option', () => {
const res = run(['repo', 'create', '--help']);
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /-o, --owner <owner>/);
});
test('release asset commands expose repeatable assets and a single-asset name override', () => {
const create = run(['release', 'create', '--help']);
assert.equal(create.status, 0, create.stderr);
assert.match(create.stdout, /--asset <path>/);
assert.match(create.stdout, /--asset-name <name>/);
const upload = run(['release', 'upload', '--help']);
assert.equal(upload.status, 0, upload.stderr);
assert.match(upload.stdout, /--tag <tag>/);
assert.match(upload.stdout, /--asset <path>/);
assert.match(upload.stdout, /--asset-name <name>/);
});
test('release create rejects one asset name for multiple assets before reading config', () => {
const res = run([
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', 'one.bin', '--asset', 'two.bin', '--asset-name', 'named.bin',
], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) });
assert.equal(res.status, 1);
assert.match(res.stderr, /--asset-name requires exactly one --asset/);
assert.doesNotMatch(res.stderr, /Not authenticated/);
});
test('release upload rejects zero assets before reading config', () => {
const res = run([
'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1',
], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) });
assert.equal(res.status, 1);
assert.match(res.stderr, /at least one --asset is required/i);
assert.doesNotMatch(res.stderr, /Not authenticated/);
});
test('release create prints the id and uploads every asset as multipart data', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-create-'));
const cfg = path.join(dir, 'config.json');
const first = path.join(dir, 'first.bin');
const second = path.join(dir, 'second.bin');
fs.writeFileSync(first, 'first payload');
fs.writeFileSync(second, 'second payload');
const requests = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
requests.push({
method: req.method,
url: req.url,
contentType: req.headers['content-type'],
body: Buffer.concat(chunks).toString('utf8'),
});
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/repos/o/r/releases') {
res.writeHead(201);
res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'Version 1', html_url: 'https://forge.test/o/r/releases/v1' }));
} else {
res.writeHead(201);
res.end(JSON.stringify({ id: requests.length, name: new URL(req.url, 'http://local').searchParams.get('name') }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync([
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', first, '--asset', second,
], { STOKE_CONFIG_FILE: cfg });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Release id: 42/i);
assert.match(result.stdout, /Asset uploaded: first\.bin/);
assert.match(result.stdout, /Asset uploaded: second\.bin/);
assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
{ method: 'POST', url: '/api/v1/repos/o/r/releases' },
{ method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=first.bin' },
{ method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=second.bin' },
]);
assert.match(requests[1].contentType, /^multipart\/form-data; boundary=/);
assert.match(requests[1].body, /first payload/);
assert.match(requests[2].body, /second payload/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release upload resolves the tag once and applies a single asset name override', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-upload-'));
const cfg = path.join(dir, 'config.json');
const asset = path.join(dir, 'original.bin');
fs.writeFileSync(asset, 'upload payload');
const requests = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString('utf8') });
res.setHeader('Content-Type', 'application/json');
if (req.method === 'GET') {
res.end(JSON.stringify({ id: 7, tag_name: 'v1' }));
} else {
res.writeHead(201);
res.end(JSON.stringify({ id: 8, name: 'renamed.bin' }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync([
'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', asset, '--asset-name', 'renamed.bin',
], { STOKE_CONFIG_FILE: cfg });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Asset uploaded: renamed\.bin/);
assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
{ method: 'GET', url: '/api/v1/repos/o/r/releases/tags/v1' },
{ method: 'POST', url: '/api/v1/repos/o/r/releases/7/assets?name=renamed.bin' },
]);
assert.match(requests[1].body, /upload payload/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release upload streams a large asset through receiver backpressure', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-large-upload-'));
const cfg = path.join(dir, 'config.json');
const asset = path.join(dir, 'large.bin');
const assetSize = 8 * 1024 * 1024;
fs.writeFileSync(asset, Buffer.alloc(assetSize, 0x61));
let uploadedBytes = 0;
let paused = false;
const server = http.createServer((req, res) => {
if (req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 7, tag_name: 'v1' }));
return;
}
req.on('data', (chunk) => {
uploadedBytes += chunk.length;
if (!paused) {
paused = true;
req.pause();
setTimeout(() => req.resume(), 100);
}
});
req.on('end', () => {
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 8, name: 'large.bin' }));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync(
['release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1', '--asset', asset],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(paused, true);
assert.ok(uploadedBytes > assetSize, `multipart body ${uploadedBytes} did not include ${assetSize} asset bytes`);
assert.match(result.stdout, /Asset uploaded: large\.bin/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release create keeps the release and reports landed and failed assets', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-partial-'));
const cfg = path.join(dir, 'config.json');
const good = path.join(dir, 'good.bin');
const bad = path.join(dir, 'bad.bin');
fs.writeFileSync(good, 'good');
fs.writeFileSync(bad, 'bad');
const requests = [];
const server = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
requests.push({ method: req.method, url: req.url });
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/repos/o/r/releases') {
res.writeHead(201);
res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'v1', html_url: 'https://forge.test/release/v1' }));
} else if (req.url.includes('good.bin')) {
res.writeHead(201);
res.end(JSON.stringify({ id: 1, name: 'good.bin' }));
} else {
res.writeHead(500);
res.end(JSON.stringify({ message: 'storage unavailable' }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync([
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', bad, '--asset', good,
], { STOKE_CONFIG_FILE: cfg });
assert.equal(result.status, 1);
assert.match(result.stdout, /Release id: 42/i);
assert.match(result.stdout, /Asset uploaded: good\.bin/);
assert.match(result.stderr, /Asset failed: bad\.bin: storage unavailable/);
assert.match(result.stderr, /release was kept/i);
assert.deepEqual(requests.map(({ url }) => url), [
'/api/v1/repos/o/r/releases',
'/api/v1/repos/o/r/releases/42/assets?name=bad.bin',
'/api/v1/repos/o/r/releases/42/assets?name=good.bin',
]);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release view lists attached assets with their sizes and download URLs', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-view-assets-'));
const cfg = path.join(dir, 'config.json');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
id: 42,
tag_name: 'v1',
name: 'Version 1',
html_url: 'https://forge.test/o/r/releases/v1',
target_commitish: 'main',
author: { login: 'bot' },
published_at: '2026-08-30T00:00:00Z',
body: '',
assets: [
{ name: 'first.bin', size: 12, browser_download_url: 'https://forge.test/assets/first.bin' },
{ name: 'second.bin', size: 2048, browser_download_url: 'https://forge.test/assets/second.bin' },
],
}));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync(
['release', 'view', '-o', 'o', '-r', 'r', '--tag', 'v1'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Assets:/);
assert.match(result.stdout, /first\.bin \(12 bytes\) https:\/\/forge\.test\/assets\/first\.bin/);
assert.match(result.stdout, /second\.bin \(2048 bytes\) https:\/\/forge\.test\/assets\/second\.bin/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('repo create surfaces an organization permission failure and HTTP status', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-repo-create-403.json`);
const requests = [];
const server = http.createServer((req, res) => {
requests.push({ method: req.method, url: req.url });
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/user') {
res.writeHead(200);
res.end(JSON.stringify({ login: 'buildbot' }));
return;
}
res.writeHead(403);
res.end(JSON.stringify({ message: 'user does not have permission to create repositories' }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['repo', 'create', '--owner', 'heavy-duty', '--name', 'project'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(res.status, 1);
assert.match(res.stderr, /user does not have permission to create repositories/);
assert.match(res.stderr, /HTTP status: 403/);
assert.deepEqual(requests, [
{ method: 'GET', url: '/api/v1/user' },
{ method: 'POST', url: '/api/v1/orgs/heavy-duty/repos' },
]);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.unlinkSync(cfg);
}
});
test('pr merge validates --number before any network call', () => {
const res = run(['pr', 'merge', '-o', 'o', '-r', 'r', '-n', 'seven']);
assert.equal(res.status, 1);
@ -77,6 +427,75 @@ test('issue create --body-file reports unreadable files cleanly', () => {
}
});
test('issue create resolves repeated label names into the initial create payload', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-issue-labels.json`);
const requests = [];
const server = http.createServer((req, res) => {
let data = '';
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body: data });
res.writeHead(req.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' });
if (req.method === 'GET') {
res.end(JSON.stringify([
{ id: 107, name: 'ready', color: '0e8a16' },
{ id: 100, name: 'enhancement', color: '84b6eb' },
]));
} else {
res.end(JSON.stringify({ number: 27, title: 'Probe', html_url: 'https://forge.test/o/r/issues/27' }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['issue', 'create', '-o', 'o', '-r', 'r', '-t', 'Probe', '--label', 'ready', '--label', 'enhancement'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(requests.map(({ method }) => method), ['GET', 'POST']);
assert.match(requests[0].url, /^\/api\/v1\/repos\/o\/r\/labels\?/);
assert.equal(requests[1].url, '/api/v1/repos/o/r/issues');
assert.deepEqual(JSON.parse(requests[1].body), {
title: 'Probe',
body: '',
labels: [107, 100],
});
} finally {
await new Promise((resolve) => server.close(resolve));
fs.unlinkSync(cfg);
}
});
test('issue create rejects an unknown label before creating the issue', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-unknown-issue-label.json`);
let createRequests = 0;
const server = http.createServer((req, res) => {
if (req.method === 'POST') createRequests += 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 107, name: 'ready', color: '0e8a16' }]));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['issue', 'create', '-o', 'o', '-r', 'r', '-t', 'Probe', '--label', 'nonexistent'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(res.status, 1);
assert.match(res.stderr, /Label not found in o\/r: nonexistent/);
assert.equal(createRequests, 0);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.unlinkSync(cfg);
}
});
test('pr show validates --number before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
@ -176,6 +595,132 @@ test('pr review approve allows an empty body before any network call', () => {
}
});
test('label create rejects an invalid color before any network call', () => {
const res = run(['label', 'create', '-o', 'o', '-r', 'r', '--name', 'x', '--color', 'red']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Color must be 6 hex digits/);
});
test('label delete requires one of --id or --name before any network call', () => {
const res = run(['label', 'delete', '-o', 'o', '-r', 'r']);
assert.equal(res.status, 1);
assert.match(res.stderr, /One of --id or --name is required/);
});
test('api rejects an endpoint without a leading slash before any network call', () => {
const res = run(['api', 'repos/o/r']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Endpoint must start with \//);
});
test('api rejects an unsupported method before any network call', () => {
const res = run(['api', '/user', '-X', 'HEAD']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Unsupported method/);
});
test('api rejects --paginate with a non-GET method before any network call', () => {
const res = run(['api', '/user', '-X', 'POST', '--paginate']);
assert.equal(res.status, 1);
assert.match(res.stderr, /--paginate only works with GET/);
});
test('api rejects GET with --input before any network call', () => {
const res = run(['api', '/user', '-X', 'GET', '--input', '{}']);
assert.equal(res.status, 1);
assert.match(res.stderr, /GET requests cannot carry a body/);
});
test('label delete rejects --id and --name together before any network call', () => {
const res = run(['label', 'delete', '-o', 'o', '-r', 'r', '--id', '3', '--name', 'x']);
assert.equal(res.status, 1);
assert.match(res.stderr, /either --id or --name, not both/);
});
test('api rejects invalid JSON input before any network call', () => {
const res = run(['api', '/user', '--input', '{nope']);
assert.equal(res.status, 1);
assert.match(res.stderr, /not valid JSON/);
});
test('api sends the token and prints the JSON response', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-api.json`);
const TIMEOUT_MS = 5000;
let timer;
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
let authHeader = null;
const server = http.createServer((req, res) => {
authHeader = req.headers.authorization;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ login: 'bot' }));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(['api', '/user'], { STOKE_CONFIG_FILE: cfg });
clearTimeout(timer);
server.close(() => resolve({ res, authHeader }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.res.status, 0, result.res.stderr);
assert.equal(result.authHeader, 'token tok');
assert.deepEqual(JSON.parse(result.res.stdout), { login: 'bot' });
} finally {
fs.unlinkSync(cfg);
}
});
test('label add fails closed on an unknown label name', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-lbl.json`);
const TIMEOUT_MS = 5000;
let timer;
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, name: 'bug', color: 'd73a4a' }]));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['label', 'add', '-o', 'o', '-r', 'r', '-n', '7', '--name', 'ghost'],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve(res));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.status, 1);
assert.match(result.stderr, /Label not found in o\/r: ghost/);
} finally {
fs.unlinkSync(cfg);
}
});
function spawnAsync(args, env = {}) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI, ...args], {
@ -296,3 +841,301 @@ test('pr review prints the review URL from the API response', async () => {
fs.unlinkSync(cfg);
}
});
// Runs `auth login` against a stub Forgejo server and captures the body of
// the token-creation request. GETs answer as /user; the POST to
// /users/{name}/tokens is what carries the scopes under test.
function runLoginWithServer(extraArgs) {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-${runLoginWithServer.n}.json`);
runLoginWithServer.n += 1;
const TIMEOUT_MS = 5000;
let timer;
return new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
let tokenBody = null;
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url.startsWith('/api/v1/users/')) {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
tokenBody = JSON.parse(data);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 1, name: tokenBody.name, sha1: 'tok123' }));
});
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ login: 'alice', username: 'alice', email: 'alice@forge.test' }));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
try {
const res = await spawnAsync(
['auth', 'login', '-u', `http://127.0.0.1:${port}`, '-n', 'alice', '-p', 'secret', ...extraArgs],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve({ res, tokenBody, cfg }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
}
runLoginWithServer.n = 0;
test('auth login creates a token with the reduced default scopes', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer([]);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, [
'read:issue', 'write:issue',
'read:repository', 'write:repository',
'read:user',
'read:organization',
]);
assert.match(res.stdout, /Scopes: read:issue, write:issue, read:repository, write:repository, read:user, read:organization/);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login --full-scopes restores the full scope set', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer(['--full-scopes']);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, [
'read:activitypub', 'write:activitypub',
'read:issue', 'write:issue',
'read:misc', 'write:misc',
'read:organization', 'write:organization',
'read:package', 'write:package',
'read:repository', 'write:repository',
'read:user', 'write:user',
]);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login --scopes parses a comma-separated list', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer(['--scopes', 'read:issue, write:repository ,read:user']);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, ['read:issue', 'write:repository', 'read:user']);
assert.match(res.stdout, /Scopes: read:issue, write:repository, read:user/);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login rejects --full-scopes together with --scopes before any network call', () => {
const res = run(['auth', 'login', '--full-scopes', '--scopes', 'read:issue']);
assert.equal(res.status, 1);
assert.match(res.stderr, /either --full-scopes or --scopes, not both/);
});
test('issue show validates --number before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['issue', 'show', '-o', 'o', '-r', 'r', '-n', 'zero'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Id must be a positive integer/);
} finally {
fs.unlinkSync(cfg);
}
});
test('issue comment rejects a missing body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Comment body is required/);
} finally {
fs.unlinkSync(cfg);
}
});
test('issue comment rejects a whitespace-only body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1', '-b', ' '], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Comment body is required/);
} finally {
fs.unlinkSync(cfg);
}
});
test('issue comment posts to the issue comments endpoint', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-ic.json`);
const TIMEOUT_MS = 5000;
let timer;
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
let request = null;
const server = http.createServer((req, res) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
request = { url: req.url, body: data };
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 9, html_url: 'https://forge.test/issues/7#issuecomment-9' }));
});
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '7', '-b', 'Confirmed.'],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve({ res, request }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.res.status, 0, result.res.stderr);
assert.equal(result.request.url, '/api/v1/repos/o/r/issues/7/comments');
assert.equal(JSON.parse(result.request.body).body, 'Confirmed.');
assert.match(result.res.stdout, /Comment added to #7\./);
assert.match(result.res.stdout, /URL: https:\/\/forge\.test\/issues\/7#issuecomment-9/);
} finally {
fs.unlinkSync(cfg);
}
});
test('read commands print the raw API JSON with --json', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-json.json`);
const payloads = {
'/api/v1/user': { login: 'bot' },
'/api/v1/user/repos': [{ full_name: 'o/r' }],
'/api/v1/repos/o/r/issues': [{ number: 7, title: 'Bug' }],
'/api/v1/repos/o/r/issues/7': { number: 7, title: 'Bug', state: 'open' },
'/api/v1/repos/o/r/pulls': [{ number: 3, title: 'Fix' }],
'/api/v1/repos/o/r/pulls/3': { number: 3, title: 'Fix', state: 'open' },
};
const commands = [
[['auth', 'status', '--json'], { login: 'bot' }],
[['repo', 'list', '--json'], [{ full_name: 'o/r' }]],
[['issue', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 7, title: 'Bug' }]],
[['issue', 'show', '-o', 'o', '-r', 'r', '-n', '7', '--json'], { number: 7, title: 'Bug', state: 'open' }],
[['pr', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 3, title: 'Fix' }]],
[['pr', 'show', '-o', 'o', '-r', 'r', '-n', '3', '--json'], { number: 3, title: 'Fix', state: 'open' }],
];
const TIMEOUT_MS = 5000;
let timer;
const results = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
const payload = payloads[pathname];
if (!payload) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: `no mock for ${pathname}` }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const out = [];
for (const [args] of commands) {
out.push(await spawnAsync(args, { STOKE_CONFIG_FILE: cfg }));
}
clearTimeout(timer);
server.close(() => resolve(out));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
results.forEach((res, i) => {
const [args, expected] = commands[i];
assert.equal(res.status, 0, `${args.join(' ')}: ${res.stderr}`);
assert.deepEqual(JSON.parse(res.stdout), expected);
});
} finally {
fs.unlinkSync(cfg);
}
});
test('pr review --commit sends commit_id only when given', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-commit.json`);
const TIMEOUT_MS = 5000;
let timer;
const bodies = [];
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
bodies.push(JSON.parse(data));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 42 }));
});
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const withCommit = await spawnAsync(
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve', '--commit', 'abc123'],
{ STOKE_CONFIG_FILE: cfg },
);
const withoutCommit = await spawnAsync(
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve'],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve({ withCommit, withoutCommit }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.withCommit.status, 0, result.withCommit.stderr);
assert.equal(result.withoutCommit.status, 0, result.withoutCommit.stderr);
assert.equal(bodies[0].commit_id, 'abc123');
assert.ok(!('commit_id' in bodies[1]));
} finally {
fs.unlinkSync(cfg);
}
});

109
test/clone.test.js Normal file
View file

@ -0,0 +1,109 @@
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync, execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
const TOKEN = 'stoke-secret-token-for-clone-tests';
// A local stand-in for the forge: a directory holding bare repositories laid
// out as <owner>/<repo>.git, so config.url can point at it with a file:// URL
// and `repo clone` exercises real git clones without any network.
let root;
let remote;
let work;
let cfg;
function git(args, cwd) {
return execFileSync('git', args, { cwd: cwd || root, encoding: 'utf8' });
}
function run(args, cwd) {
return spawnSync(process.execPath, [CLI, ...args], {
cwd: cwd || work,
encoding: 'utf8',
env: { ...process.env, STOKE_CONFIG_FILE: cfg },
});
}
before(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-clone-test-'));
remote = path.join(root, 'remote');
work = path.join(root, 'work');
const seed = path.join(root, 'seed');
fs.mkdirSync(path.join(remote, 'o'), { recursive: true });
fs.mkdirSync(work);
git(['init', '-b', 'main', seed]);
fs.writeFileSync(path.join(seed, 'README.md'), 'hello from seed\n');
git(['-C', seed, 'add', 'README.md']);
git(['-C', seed, '-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', 'initial']);
git(['clone', '--bare', seed, path.join(remote, 'o', 'r.git')]);
cfg = path.join(root, 'config.json');
fs.writeFileSync(cfg, JSON.stringify({ url: `file://${remote}`, token: TOKEN, login: 'tester' }));
});
after(() => {
fs.rmSync(root, { recursive: true, force: true });
});
test('repo clone rejects an invalid --depth before running git', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--depth', 'zero']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Depth must be a positive integer/);
});
test('repo clone defaults the destination to the repository name', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r']);
assert.equal(res.status, 0, res.stderr);
const dest = path.join(work, 'r');
assert.ok(fs.existsSync(path.join(dest, '.git')));
assert.equal(fs.readFileSync(path.join(dest, 'README.md'), 'utf8'), 'hello from seed\n');
});
test('repo clone honors an explicit destination directory', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'custom-dir']);
assert.equal(res.status, 0, res.stderr);
assert.ok(fs.existsSync(path.join(work, 'custom-dir', '.git')));
});
test('repo clone fails with git\'s status when the destination is not empty', () => {
const dest = path.join(work, 'occupied');
fs.mkdirSync(dest);
fs.writeFileSync(path.join(dest, 'file.txt'), 'in the way\n');
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'occupied']);
assert.equal(res.status, 128);
assert.match(res.stderr, /already exists and is not an empty directory/);
});
test('repo clone propagates git\'s failure for a missing repository', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
assert.equal(res.status, 128);
assert.match(res.stderr, /does not appear to be a git repository|repository.*does not exist/i);
});
test('repo clone --origin sets the remote name', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--origin', 'upstream', 'named-origin']);
assert.equal(res.status, 0, res.stderr);
const url = git(['-C', path.join(work, 'named-origin'), 'config', 'remote.upstream.url']);
assert.ok(url.trim().endsWith('/o/r.git'));
});
test('repo clone never exposes the token in output or repository config', () => {
const ok = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'redacted']);
assert.equal(ok.status, 0, ok.stderr);
const fail = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
for (const output of [ok.stdout, ok.stderr, fail.stdout, fail.stderr]) {
assert.ok(!output.includes(TOKEN), 'token leaked into CLI output');
}
const dest = path.join(work, 'redacted');
const gitConfig = fs.readFileSync(path.join(dest, '.git', 'config'), 'utf8');
assert.ok(!gitConfig.includes(TOKEN), 'token persisted in .git/config');
const remoteUrl = git(['-C', dest, 'config', 'remote.origin.url']);
assert.ok(!remoteUrl.includes(TOKEN), 'token persisted in the remote URL');
});

269
test/governance.test.js Normal file
View file

@ -0,0 +1,269 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-governance.js');
const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf');
const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
const PACKAGE_MANIFEST = path.join(__dirname, '..', 'package.json');
const PACKAGE_LOCK = path.join(__dirname, '..', 'package-lock.json');
const CEREMONY_REPOSITORY = 'https://forgejo.heavyduty.builders/heavy-duty/ceremony';
const CEREMONY_VERSION = '0.6.3';
const CEREMONY_WORKFLOWS = ['labels.yml', 'labels-sweep.yml'];
const cleanups = [];
process.on('exit', () => {
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
});
function writeConfig(contents) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-test-'));
cleanups.push(dir);
const config = path.join(dir, 'labels.conf');
fs.writeFileSync(config, contents);
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
return config;
}
function writeRepository(configContents, labelerContents, files = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-repository-'));
cleanups.push(dir);
fs.mkdirSync(path.join(dir, '.github'), { recursive: true });
fs.writeFileSync(path.join(dir, '.github', 'labels.conf'), configContents);
fs.writeFileSync(path.join(dir, '.github', 'labeler.yml'), labelerContents);
for (const [filename, contents] of Object.entries(files)) {
const target = path.join(dir, filename);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
}
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '.'], { cwd: dir });
return { dir, config: path.join(dir, '.github', 'labels.conf') };
}
function runValidator(config, apiUrl, cwd = path.join(__dirname, '..')) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
cwd,
encoding: 'utf8',
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('close', (status) => resolve({ status, stdout, stderr }));
});
}
async function withIdentityServer(logins, callback, redirects = new Map()) {
const server = http.createServer((request, response) => {
const login = decodeURIComponent(request.url.replace('/api/v1/users/', ''));
if (redirects.has(login)) {
response.writeHead(307, { location: `/api/v1/users/${redirects.get(login)}` });
response.end();
return;
}
response.writeHead(logins.has(login) ? 200 : 404, { 'content-type': 'application/json' });
response.end(JSON.stringify(logins.has(login) ? { login } : { message: 'not found' }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
try {
await callback(`http://127.0.0.1:${port}/api/v1`);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
const validConfig = [
'panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl',
'triage-actors=claude-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)',
'scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path',
'scope:manifests|C5DEF5|manifests/ — the fleet repo registry data',
'scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates',
'scope:docs|C5DEF5|README and docs/ — the prose contract',
].join('\n');
test('governance validator accepts the configured roster when every identity resolves', async () => {
const config = writeConfig(`${validConfig}\n`);
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
await withIdentityServer(logins, async (apiUrl) => {
const result = await runValidator(config, apiUrl);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
});
});
test('governance validator fails when a roster identity does not resolve', async () => {
const config = writeConfig(`${validConfig.replace('kimi-bot-andresmgsl', 'kimi-bto-andresmgsl')}\n`);
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
await withIdentityServer(logins, async (apiUrl) => {
const result = await runValidator(config, apiUrl);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /kimi-bto-andresmgsl.*HTTP 404/);
});
});
test('governance validator rejects a renamed identity that redirects to a live login', async () => {
const config = writeConfig(`${validConfig.replaceAll('claude-bot-andresmgsl', 'cluade-bot-andresmgsl')}\n`);
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
const redirects = new Map([['cluade-bot-andresmgsl', 'claude-bot-andresmgsl']]);
await withIdentityServer(logins, async (apiUrl) => {
const result = await runValidator(config, apiUrl);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /cluade-bot-andresmgsl.*HTTP 307/);
}, redirects);
});
test('governance validator rejects malformed scope rows before identity requests', async () => {
const config = writeConfig(`${validConfig.replace('|C5DEF5|', '|not-a-color|')}\n`);
const result = await runValidator(config, 'http://127.0.0.1:1/api/v1');
assert.notEqual(result.status, 0);
assert.match(result.stderr, /malformed label row/);
assert.doesNotMatch(result.stderr, /fetch failed/);
});
test('governance validator rejects a tracked path outside every scope and the residue allowlist', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
'scope:ci|C5DEF5|.github/ — governance surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
'"scope:ci":',
' - changed-files:',
' - any-glob-to-any-file: [".github/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, {
'src/covered.js': '',
'new-surface/uncovered.txt': '',
});
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /tracked paths have no scope mapping: new-surface\/uncovered\.txt/);
});
});
test('governance validator rejects scope names declared only in the labeler map', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
'"scope:extra":',
' - changed-files:',
' - any-glob-to-any-file: ["extra/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /scope names only in labeler\.yml: scope:extra/);
});
});
test('governance validator rejects scope names declared only in labels.conf', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
'scope:renamed|C5DEF5|renamed/ — renamed surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /scope names only in labels\.conf: scope:renamed/);
});
});
test('repository governance config resolves the current four-member panel and five scopes', async () => {
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
await withIdentityServer(logins, async (apiUrl) => {
const result = await runValidator(REPOSITORY_CONFIG, apiUrl);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
});
});
test('repository scope mapping covers every tracked path except the ruled residue', () => {
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
.flatMap((match) => JSON.parse(match[1]));
const tracked = execFileSync('git', ['ls-files'], {
cwd: path.join(__dirname, '..'),
encoding: 'utf8',
}).trim().split('\n');
const allowed = new Set([
'.gitignore',
'assets/logo-mark.svg',
...tracked.filter((filename) => /^test\/[^/]+\.test\.js$/.test(filename)),
]);
const uncovered = tracked.filter((filename) => (
!allowed.has(filename) && !globs.some((glob) => path.matchesGlob(filename, glob))
));
assert.deepEqual(uncovered, []);
});
test('package lock versions match the package manifest', () => {
const manifest = JSON.parse(fs.readFileSync(PACKAGE_MANIFEST, 'utf8'));
const lock = JSON.parse(fs.readFileSync(PACKAGE_LOCK, 'utf8'));
assert.equal(lock.version, manifest.version, 'package-lock.json version is stale');
assert.equal(lock.packages[''].version, manifest.version, 'package-lock.json root package version is stale');
});
test('repository carries the complete Forgejo 0.6.3 doctrine mirror and root router', () => {
const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
for (const filename of vendored) {
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`);
}
const mirrorReadme = fs.readFileSync(path.join(REPOSITORY_MIRROR, 'README.md'), 'utf8');
const sourceVersionRecord = `[heavy-duty/ceremony](${CEREMONY_REPOSITORY}) at ${CEREMONY_VERSION}`;
assert.equal(
mirrorReadme.split(sourceVersionRecord).length - 1,
2,
'mirror README does not identify the exact Forgejo ceremony source and version in both records',
);
assert.match(mirrorReadme, /labels doctrine is vendored manually/);
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/);
const rootAgents = fs.readFileSync(ROOT_AGENTS, 'utf8');
assert.ok(
rootAgents.includes(`[heavy-duty/ceremony](${CEREMONY_REPOSITORY})`),
'root router does not identify the Forgejo ceremony repository',
);
assert.match(rootAgents, /read\s+`.ceremony\/AGENTS\.md` first/i);
});
test('repository workflow pins use the exact Forgejo ceremony version', () => {
for (const workflow of CEREMONY_WORKFLOWS) {
const contents = fs.readFileSync(path.join(__dirname, '..', '.forgejo', 'workflows', workflow), 'utf8');
const prefix = `uses: heavy-duty/ceremony/.github/workflows/${workflow}@`;
const pins = contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith(prefix));
assert.deepEqual(
pins,
[`${prefix}${CEREMONY_VERSION}`],
`${workflow} does not pin ceremony ${CEREMONY_VERSION}`,
);
}
});

248
test/import-batch.test.js Normal file
View file

@ -0,0 +1,248 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
function run(args, env = {}) {
return new Promise((resolve, reject) => {
const childEnv = { ...process.env, ...env };
childEnv.NODE_OPTIONS = [
childEnv.NODE_OPTIONS,
'--disable-warning=ExperimentalWarning',
].filter(Boolean).join(' ');
const child = spawn(process.execPath, [CLI, ...args], {
env: childEnv,
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', reject);
child.on('close', (status) => resolve({ status, stdout, stderr }));
});
}
async function startMigrationServer() {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
const payload = JSON.parse(body);
requests.push({ method: req.method, url: req.url, body: payload });
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
full_name: `destination/${payload.repo_name}`,
html_url: `https://forge.test/destination/${payload.repo_name}`,
}));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
return { server, requests };
}
test('repo import-batch continues after one item has no source token', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-'));
const configFile = path.join(dir, 'config.json');
const manifestFile = path.join(dir, 'manifest.json');
const emptyPath = path.join(dir, 'bin');
const forgeToken = 'forge-token-must-not-be-printed';
const { server, requests } = await startMigrationServer();
fs.mkdirSync(emptyPath);
fs.writeFileSync(configFile, JSON.stringify({
url: `http://127.0.0.1:${server.address().port}`,
login: 'destination',
token: forgeToken,
}));
fs.writeFileSync(manifestFile, JSON.stringify([
{ name: 'missing-token', from: 'https://github.com/source/first.git', service: 'github' },
{ name: 'imported-second', from: 'https://git.example/source/second.git', service: 'git' },
]));
try {
const result = await run(
['--config', configFile, 'repo', 'import-batch', '--file', manifestFile],
{ PATH: emptyPath, GITHUB_TOKEN: undefined },
);
assert.equal(result.status, 1);
assert.match(result.stderr, /Failed to import missing-token: No GitHub token found\./);
assert.equal(result.stdout,
'Imported: destination/imported-second -> https://forge.test/destination/imported-second\n'
+ '\nBatch complete: 1/2 imported.\n');
assert.deepEqual(requests, [{
method: 'POST',
url: '/api/v1/repos/migrate',
body: {
clone_addr: 'https://git.example/source/second.git',
repo_name: 'imported-second',
repo_owner: 'destination',
service: 'git',
private: false,
issues: true,
labels: true,
milestones: true,
pull_requests: true,
releases: true,
wiki: true,
lfs: false,
},
}]);
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(forgeToken));
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('repo import-batch preserves successful batch output', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-success-'));
const configFile = path.join(dir, 'config.json');
const manifestFile = path.join(dir, 'manifest.json');
const { server, requests } = await startMigrationServer();
fs.writeFileSync(configFile, JSON.stringify({
url: `http://127.0.0.1:${server.address().port}`,
login: 'destination',
token: 'forge-token-must-not-be-printed',
}));
fs.writeFileSync(manifestFile, JSON.stringify([
{ name: 'first', from: 'https://git.example/source/first.git', service: 'git' },
{ name: 'second', from: 'https://git.example/source/second.git', service: 'git' },
]));
try {
const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.equal(result.stdout,
'Imported: destination/first -> https://forge.test/destination/first\n'
+ 'Imported: destination/second -> https://forge.test/destination/second\n'
+ '\nBatch complete: 2/2 imported.\n');
assert.deepEqual(requests.map(({ body }) => body.repo_name), ['first', 'second']);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('repo import-batch sends an explicit GitHub token without printing it', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-token-'));
const configFile = path.join(dir, 'config.json');
const manifestFile = path.join(dir, 'manifest.json');
const emptyPath = path.join(dir, 'bin');
const sourceToken = 'github-token-must-not-be-printed';
const { server, requests } = await startMigrationServer();
fs.mkdirSync(emptyPath);
fs.writeFileSync(configFile, JSON.stringify({
url: `http://127.0.0.1:${server.address().port}`,
login: 'destination',
token: 'forge-token-must-not-be-printed',
}));
fs.writeFileSync(manifestFile, JSON.stringify([{
name: 'from-github',
from: 'https://github.com/source/repository.git',
service: 'github',
github_token: sourceToken,
}]));
try {
const result = await run(
['--config', configFile, 'repo', 'import-batch', '--file', manifestFile],
{ PATH: emptyPath, GITHUB_TOKEN: undefined },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.equal(result.stdout,
'Imported: destination/from-github -> https://forge.test/destination/from-github\n'
+ '\nBatch complete: 1/1 imported.\n');
assert.deepEqual(requests, [{
method: 'POST',
url: '/api/v1/repos/migrate',
body: {
clone_addr: 'https://github.com/source/repository.git',
repo_name: 'from-github',
repo_owner: 'destination',
service: 'github',
private: false,
issues: true,
labels: true,
milestones: true,
pull_requests: true,
releases: true,
wiki: true,
lfs: false,
auth_token: sourceToken,
},
}]);
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(sourceToken));
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('repo import-batch keeps file and JSON errors at batch level', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-invalid-'));
const configFile = path.join(dir, 'config.json');
const missingFile = path.join(dir, 'missing.json');
const malformedFile = path.join(dir, 'malformed.json');
fs.writeFileSync(configFile, JSON.stringify({
url: 'https://forge.test',
login: 'destination',
token: 'forge-token-must-not-be-printed',
}));
fs.writeFileSync(malformedFile, '{not json');
try {
const missing = await run(['--config', configFile, 'repo', 'import-batch', '--file', missingFile]);
const malformed = await run(['--config', configFile, 'repo', 'import-batch', '--file', malformedFile]);
assert.equal(missing.status, 1);
assert.match(missing.stderr, /^Batch import failed: ENOENT:/);
assert.equal(missing.stdout, '');
assert.equal(malformed.status, 1);
assert.match(malformed.stderr, /^Batch import failed: /);
assert.match(malformed.stderr, /JSON/);
assert.equal(malformed.stdout, '');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('repo import-batch excludes skipped invalid entries from the summary', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-skip-'));
const configFile = path.join(dir, 'config.json');
const manifestFile = path.join(dir, 'manifest.json');
const { server, requests } = await startMigrationServer();
fs.writeFileSync(configFile, JSON.stringify({
url: `http://127.0.0.1:${server.address().port}`,
login: 'destination',
token: 'forge-token-must-not-be-printed',
}));
fs.writeFileSync(manifestFile, JSON.stringify([
{ name: 'missing-source' },
{ name: 'valid', from: 'https://git.example/source/valid.git', service: 'git' },
]));
try {
const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, 'Skipping invalid manifest entry: {"name":"missing-source"}\n');
assert.match(result.stdout, /Batch complete: 1\/1 imported\./);
assert.deepEqual(requests.map(({ body }) => body.repo_name), ['valid']);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});

View file

@ -12,12 +12,17 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh');
// candInitial `apt-cache policy` Candidate before any update
// candAfterUpdate Candidate after any `apt-get update`
// candAfterNodesource Candidate after an update once nodesource.list exists
// releaseStatus HTTP status curl reports for the registry Release file
// sourceUpdateError stderr and exit 100 for the first signed stoke update
// forgeUser/token private-registry credentials
// allowUnverified explicit HTTPS-only integrity opt-in
// precreateSourcesDir whether the throwaway apt root already has sources.list.d
// The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set,
// so every scenario doubles as a regression test for locale-safe parsing.
const cleanups = [];
process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); });
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList }) {
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified, precreateSourcesDir = true }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-'));
cleanups.push(root);
const bin = path.join(root, 'bin');
@ -25,7 +30,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
const aptEtc = path.join(root, 'etc', 'apt');
fs.mkdirSync(bin, { recursive: true });
fs.mkdirSync(state, { recursive: true });
fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true });
if (precreateSourcesDir) fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true });
fs.writeFileSync(path.join(state, 'candidate'), candInitial);
if (preexistingNodesourceList !== undefined) {
fs.writeFileSync(path.join(aptEtc, 'sources.list.d', 'nodesource.list'), preexistingNodesourceList);
@ -38,8 +43,35 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
// Force the non-root path so every mutation goes through the sudo stub.
stub('id', 'echo 1000');
stub('sudo', 'exec "$@"');
stub('curl', 'echo "FAKE-KEY"');
stub('sudo', 'SUDO_ACTIVE=1 exec "$@"');
stub('tee', [
'for destination in "$@"; do',
' case "$destination" in',
' */auth.conf.d/forgejo-*.conf)',
' mode="$(stat -c %a "$destination" 2>/dev/null || true)"',
' if [ "$mode" != 600 ]; then',
' echo "credential destination was not mode 0600 before write" >&2',
' exit 78',
' fi',
' ;;',
' esac',
'done',
'exec /usr/bin/tee "$@"',
].join('\n'));
// Registry Release-file probes (URLs under /dists/) answer with the
// scenario's HTTP status; everything else is a key fetch.
stub('curl', [
'uses_netrc=false',
'for a in "$@"; do [ "$a" = "--netrc-file" ] && uses_netrc=true; done',
'if [ "$uses_netrc" = true ] && [ "${SUDO_ACTIVE:-}" != 1 ]; then',
' echo "curl: root-owned netrc is unreadable without sudo" >&2',
' exit 77',
'fi',
'for a in "$@"; do',
' case "$a" in */dists/*) echo "${RELEASE_STATUS:-200}"; exit 0;; esac',
'done',
'echo "FAKE-KEY"',
].join('\n'));
stub('stoke', 'echo 1.2.0');
stub('apt-cache', [
'cand="$(cat "$STATE_DIR/candidate")"',
@ -50,8 +82,16 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
].join('\n'));
stub('apt-get', [
'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"',
'source_list=""',
'for a in "$@"; do',
' case "$a" in Dir::Etc::sourcelist=*) source_list="${a#*=}";; esac',
'done',
'for a in "$@"; do',
' if [ "$a" = update ]; then',
' if [ -n "$source_list" ] && grep -q "signed-by=" "$source_list" && [ -n "${SOURCE_UPDATE_ERROR:-}" ]; then',
' printf "%s\\n" "$SOURCE_UPDATE_ERROR" >&2',
' exit 100',
' fi',
' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then',
' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"',
' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then',
@ -73,6 +113,11 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
STATE_DIR: state,
CAND_AFTER_UPDATE: candAfterUpdate || '',
CAND_AFTER_NODESOURCE: candAfterNodesource || '',
RELEASE_STATUS: releaseStatus || '',
SOURCE_UPDATE_ERROR: sourceUpdateError || '',
FORGE_USER: forgeUser || '',
FORGE_TOKEN: forgeToken || '',
STOKE_ALLOW_UNVERIFIED_APT: allowUnverified || '',
LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C
},
});
@ -87,6 +132,9 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
forgeKeyMode: mode(path.join(aptEtc, 'keyrings', 'forgejo-heavy-duty.asc')),
forgeList: read(path.join(aptEtc, 'sources.list.d', 'forgejo-heavy-duty.list')),
forgeAuth: read(path.join(aptEtc, 'auth.conf.d', 'forgejo-heavy-duty.conf')),
forgeAuthMode: mode(path.join(aptEtc, 'auth.conf.d', 'forgejo-heavy-duty.conf')),
aptGetLog: read(path.join(state, 'apt-get.log')) || '',
};
// Drop the throwaway tree after we have read everything we need.
@ -147,3 +195,133 @@ test('pre-existing user-managed nodesource.list is never overwritten', () => {
assert.equal(s.nodesourceList, marker);
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
});
test('registry Release file 404s: fails fast with a clear message before apt runs', () => {
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '404' });
assert.notEqual(s.res.status, 0);
assert.match(s.res.stderr, /no stoke package has been published/);
assert.match(s.res.stderr, /npm/);
assert.match(s.res.stderr, /dists\/stable\/Release returned 404/);
assert.equal(s.aptGetLog, '', 'must abort before any apt-get invocation');
});
test('registry Release file present: proceeds with the install', () => {
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '200' });
assert.equal(s.res.status, 0, s.res.stderr);
assert.match(s.aptGetLog, /install -y stoke/);
});
test('fresh apt root creates sources.list.d before writing the forge source', () => {
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
precreateSourcesDir: false,
});
assert.equal(s.res.status, 0, s.res.stderr);
assert.match(s.forgeList, /\[signed-by=/);
assert.match(s.aptGetLog, /install -y stoke/);
});
test('signature verification failure refuses by default and removes the forge source', () => {
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
sourceUpdateError: 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set',
});
assert.notEqual(s.res.status, 0);
assert.equal(s.forgeList, null);
assert.match(s.res.stderr, /sqv-based apt cannot parse\s+the Forgejo registry signature/);
assert.match(s.res.stderr, /STOKE_ALLOW_UNVERIFIED_APT=1/);
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
});
test('exact opt-in permits an HTTPS-only forge source after signature failure', () => {
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
sourceUpdateError: 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set',
allowUnverified: '1',
});
assert.equal(s.res.status, 0, s.res.stderr);
assert.match(s.forgeList, /\[trusted=yes\]/);
assert.match(s.res.stderr, /OpenPGP signature verification is disabled/);
assert.match(s.res.stderr, /HTTPS-only integrity/);
assert.match(s.aptGetLog, /install -y stoke/);
});
test('opt-in cannot bypass a missing signing key', () => {
const failure = 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.';
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
sourceUpdateError: failure,
allowUnverified: '1',
});
assert.notEqual(s.res.status, 0);
assert.match(s.res.stderr, new RegExp(failure.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(s.forgeList, /\[signed-by=/);
assert.doesNotMatch(s.forgeList, /trusted=yes/);
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
});
test('opt-in cannot bypass sqv output without the known Malformed MPI failure', () => {
const failure = 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1): unexpected packet';
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
sourceUpdateError: failure,
allowUnverified: '1',
});
assert.notEqual(s.res.status, 0);
assert.match(s.res.stderr, new RegExp(failure.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(s.forgeList, /\[signed-by=/);
assert.doesNotMatch(s.forgeList, /trusted=yes/);
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
});
test('unrecognized opt-in value is rejected before configuring apt', () => {
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
allowUnverified: 'yes',
});
assert.notEqual(s.res.status, 0);
assert.match(s.res.stderr, /STOKE_ALLOW_UNVERIFIED_APT must be unset or exactly 1/);
assert.equal(s.forgeList, null);
assert.equal(s.aptGetLog, '');
});
test('network update failure stays fatal and never disables signature verification', () => {
const failure = 'Temporary failure resolving forgejo.heavyduty.builders';
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
sourceUpdateError: failure,
});
assert.notEqual(s.res.status, 0);
assert.match(s.res.stderr, new RegExp(failure));
assert.match(s.forgeList, /\[signed-by=/);
assert.doesNotMatch(s.forgeList, /trusted=yes/);
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
});
test('private-registry credentials stay in a root-readable auth file, not the source URL', () => {
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
forgeUser: 'apt-user',
forgeToken: 'secret-token',
});
assert.equal(s.res.status, 0, s.res.stderr);
assert.equal(s.forgeAuthMode, 0o600);
assert.equal(s.forgeAuth, [
'machine forgejo.heavyduty.builders',
'login apt-user',
'password secret-token',
'',
].join('\n'));
assert.doesNotMatch(s.forgeList, /apt-user|secret-token/);
});
test('incomplete private-registry credentials fail before configuring apt', () => {
const s = runScenario({
candInitial: '22.23.1-1nodesource1',
forgeUser: 'apt-user',
});
assert.notEqual(s.res.status, 0);
assert.match(s.res.stderr, /FORGE_USER and FORGE_TOKEN must be set together/);
assert.equal(s.forgeList, null);
assert.equal(s.aptGetLog, '');
});

158
test/publish-deb.test.js Normal file
View file

@ -0,0 +1,158 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'publish-deb.sh');
const TOKEN = 'deb-token-that-must-not-enter-argv';
const UPLOAD_LINE = 'Uploading stoke_2.0.0_all.deb to https://forge.example.test/api/packages/heavy-duty/debian/pool/stable/main/upload\n';
function runScenario({ token = '', httpStatus = 201, responseBody = '', curlStatus = 0 } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-publish-deb-test-'));
const home = path.join(dir, 'home');
const bin = path.join(dir, 'bin');
const runnerTemp = path.join(dir, 'runner-temp');
const log = path.join(dir, 'curl.json');
const deb = path.join(dir, 'stoke_2.0.0_all.deb');
const legacyBefore = new Set(fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('stoke-publish-response.')));
let call = null;
let result;
try {
fs.mkdirSync(home);
fs.mkdirSync(bin);
fs.mkdirSync(runnerTemp);
fs.writeFileSync(deb, 'package');
fs.writeFileSync(path.join(bin, 'curl'), `#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
const headerArg = args[args.indexOf('-H') + 1];
const headerFile = headerArg && headerArg.startsWith('@') ? headerArg.slice(1) : null;
const responseFile = args[args.indexOf('-o') + 1];
const record = { args, headerFile, responseFile };
if (headerFile) {
record.header = fs.readFileSync(headerFile, 'utf8');
record.headerMode = fs.statSync(headerFile).mode & 0o777;
record.tempDir = path.dirname(headerFile);
}
fs.writeFileSync(responseFile, process.env.CURL_RESPONSE_BODY);
fs.writeFileSync(process.env.CURL_CALL_LOG, JSON.stringify(record));
if (Number(process.env.CURL_STATUS)) process.exit(Number(process.env.CURL_STATUS));
process.stdout.write(process.env.CURL_HTTP_STATUS);
`);
fs.chmodSync(path.join(bin, 'curl'), 0o755);
result = spawnSync('bash', [SCRIPT, deb], {
encoding: 'utf8',
env: {
HOME: home,
PATH: `${bin}:${process.env.PATH}`,
RUNNER_TEMP: runnerTemp,
STOKE_CONFIG_FILE: path.join(dir, 'missing-config.json'),
STOKE_TOKEN: token,
FORGE_URL: 'https://forge.example.test',
CURL_CALL_LOG: log,
CURL_HTTP_STATUS: String(httpStatus),
CURL_RESPONSE_BODY: responseBody,
CURL_STATUS: String(curlStatus),
},
});
call = fs.existsSync(log) ? JSON.parse(fs.readFileSync(log, 'utf8')) : null;
const remainingTempEntries = fs.readdirSync(runnerTemp);
const legacyAfter = fs.readdirSync(os.tmpdir()).filter(
(name) => name.startsWith('stoke-publish-response.') && !legacyBefore.has(name),
);
return {
result,
call,
runnerTemp,
remainingTempEntries,
legacyAfter,
headerExistsAfter: call?.headerFile ? fs.existsSync(call.headerFile) : false,
responseExistsAfter: call?.responseFile ? fs.existsSync(call.responseFile) : false,
};
} finally {
if (call?.responseFile && !call.responseFile.startsWith(`${dir}${path.sep}`)) {
fs.rmSync(call.responseFile, { force: true });
}
fs.rmSync(dir, { recursive: true, force: true });
}
}
function assertCleaned(scenario) {
assert.deepEqual(scenario.remainingTempEntries, []);
assert.deepEqual(scenario.legacyAfter, []);
assert.equal(scenario.headerExistsAfter, false);
assert.equal(scenario.responseExistsAfter, false);
}
test('empty token identifies the CI secret before offering the local remedy', () => {
const scenario = runScenario();
assert.equal(scenario.result.status, 1);
assert.equal(scenario.result.stdout, '');
assert.match(scenario.result.stderr, /^error: no token\./);
assert.match(scenario.result.stderr, /STOKE_TOKEN/);
assert.match(scenario.result.stderr, /RELEASE_TOKEN/);
assert.match(scenario.result.stderr, /empty value.*secret/is);
assert.ok(scenario.result.stderr.indexOf('RELEASE_TOKEN') < scenario.result.stderr.indexOf('stoke auth login'));
assert.equal(scenario.call, null);
assertCleaned(scenario);
});
test('curl reads a private authorization header file without receiving the token in argv', () => {
const scenario = runScenario({ token: TOKEN });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.ok(scenario.call.args.includes('-H'));
assert.equal(scenario.call.args.every((arg) => !arg.includes(TOKEN)), true);
assert.equal(path.dirname(scenario.call.tempDir), scenario.runnerTemp);
assert.ok(scenario.call.headerFile.startsWith(`${scenario.call.tempDir}${path.sep}`));
assert.equal(scenario.call.header, `Authorization: token ${TOKEN}\n`);
assert.equal(scenario.call.headerMode, 0o600);
assert.equal(path.dirname(scenario.call.responseFile), scenario.call.tempDir);
assert.doesNotMatch(scenario.result.stdout, new RegExp(TOKEN));
assert.doesNotMatch(scenario.result.stderr, new RegExp(TOKEN));
assertCleaned(scenario);
});
test('201 response preserves the success transcript and removes temporary files', () => {
const scenario = runScenario({ token: TOKEN, httpStatus: 201 });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.equal(scenario.result.stdout, `${UPLOAD_LINE}Published.\n`);
assert.equal(scenario.result.stderr, '');
assertCleaned(scenario);
});
test('409 response preserves the already-published transcript and removes temporary files', () => {
const scenario = runScenario({ token: TOKEN, httpStatus: 409 });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.equal(scenario.result.stdout, `${UPLOAD_LINE}Already published (409): this exact version already exists in the registry.\n`);
assert.equal(scenario.result.stderr, '');
assertCleaned(scenario);
});
test('HTTP failure preserves the response body on stderr and removes temporary files', () => {
const scenario = runScenario({ token: TOKEN, httpStatus: 500, responseBody: 'registry rejected\n' });
assert.equal(scenario.result.status, 1);
assert.equal(scenario.result.stdout, UPLOAD_LINE);
assert.equal(scenario.result.stderr, 'error: upload failed with HTTP 500\nregistry rejected\n');
assertCleaned(scenario);
});
test('curl failure propagates its status and still removes temporary files', () => {
const scenario = runScenario({ token: TOKEN, curlStatus: 7, responseBody: 'transport failed\n' });
assert.equal(scenario.result.status, 7);
assert.equal(scenario.result.stdout, UPLOAD_LINE);
assert.equal(scenario.result.stderr, '');
assertCleaned(scenario);
});

View file

@ -0,0 +1,114 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'publish-release.sh');
const TOKEN = 'release-token-that-must-not-enter-argv';
function runScenario({ viewStatus = 0, changelog = '## 2.0.0\n\n### Added\n\n- New release flow.\n' } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-test-'));
try {
const runnerTemp = path.join(dir, 'runner-temp');
const log = path.join(dir, 'calls.jsonl');
const stub = path.join(dir, 'stoke-stub.js');
const deb = path.join(dir, 'stoke_2.0.0_all.deb');
fs.mkdirSync(runnerTemp);
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog);
fs.writeFileSync(deb, 'package');
fs.writeFileSync(stub, `#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const tokenIndex = args.indexOf('--token-file');
const configIndex = args.indexOf('--config');
const record = { args };
if (tokenIndex !== -1) {
const tokenFile = args[tokenIndex + 1];
record.tokenFile = tokenFile;
record.token = fs.readFileSync(tokenFile, 'utf8');
record.tokenMode = fs.statSync(tokenFile).mode & 0o777;
}
if (configIndex !== -1) record.config = args[configIndex + 1];
fs.appendFileSync(process.env.STOKE_CALL_LOG, JSON.stringify(record) + '\\n');
if (args.includes('release') && args.includes('view')) process.exit(Number(process.env.VIEW_STATUS));
`);
fs.chmodSync(stub, 0o755);
const result = spawnSync('bash', [SCRIPT, 'v2.0.0', '2.0.0', deb, 'heavy-duty', 'stoke'], {
cwd: dir,
encoding: 'utf8',
env: {
...process.env,
RELEASE_TOKEN: TOKEN,
GITHUB_SERVER_URL: 'https://forge.example.test',
RUNNER_TEMP: runnerTemp,
STOKE: stub,
STOKE_CALL_LOG: log,
VIEW_STATUS: String(viewStatus),
},
});
const calls = fs.existsSync(log)
? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
: [];
return { result, calls, runnerTemp };
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function command(call) {
const index = call.args.indexOf('release');
return index === -1 ? '' : call.args[index + 1];
}
test('existing release uploads the asset without creating another release', () => {
const scenario = runScenario({ viewStatus: 0 });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.deepEqual(scenario.calls.map(command).filter(Boolean), ['view', 'upload']);
assert.equal(scenario.calls.some((call) => command(call) === 'create'), false);
const upload = scenario.calls.find((call) => command(call) === 'upload');
assert.ok(upload.args.includes('--tag'));
assert.ok(upload.args.includes('v2.0.0'));
assert.ok(upload.args.includes('--asset'));
assert.ok(upload.args.some((arg) => arg.endsWith('stoke_2.0.0_all.deb')));
});
test('missing release creates it with changelog notes and the asset', () => {
const scenario = runScenario({ viewStatus: 1 });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.deepEqual(scenario.calls.map(command).filter(Boolean), ['view', 'create']);
const create = scenario.calls.find((call) => command(call) === 'create');
assert.ok(create.args.includes('--title'));
assert.ok(create.args.includes('v2.0.0'));
assert.ok(create.args.includes('--body-file'));
assert.ok(create.args.includes('--asset'));
});
test('authentication uses a 0600 token file and never puts the token in argv', () => {
const scenario = runScenario();
assert.equal(scenario.result.status, 0, scenario.result.stderr);
const auth = scenario.calls[0];
assert.ok(auth.args.includes('auth'));
assert.ok(auth.args.includes('login'));
assert.ok(auth.args.includes('--token-file'));
assert.equal(auth.token, TOKEN);
assert.equal(auth.tokenMode, 0o600);
assert.equal(auth.args.includes('https://forge.example.test'), true);
assert.equal(scenario.calls.every((call) => call.args.every((arg) => !arg.includes(TOKEN))), true);
assert.equal(scenario.calls.every((call) => call.config === auth.config), true);
assert.equal(fs.existsSync(auth.tokenFile), false, 'temporary credential file must be removed');
});
test('missing changelog section aborts before any release command', () => {
const scenario = runScenario({ changelog: '## 1.0.0\n\n- Old release.\n' });
assert.equal(scenario.result.status, 1);
assert.match(scenario.result.stderr, /no section for '2\.0\.0'/);
assert.deepEqual(scenario.calls.map(command).filter(Boolean), []);
});

327
test/sync.test.js Normal file
View file

@ -0,0 +1,327 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
const TOKEN = 'stoke-secret-token-for-sync-tests';
const BASIC_CREDENTIAL = Buffer.from(`tester:${TOKEN}`).toString('base64');
const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
function git(args, cwd) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
}
function commit(directory, message, contents) {
fs.writeFileSync(path.join(directory, 'content.txt'), `${contents}\n`);
git(['add', 'content.txt'], directory);
git(['-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', message], directory);
return git(['rev-parse', 'HEAD'], directory);
}
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-sync-test-'));
const forgeRoot = path.join(root, 'forge');
const forgeRepo = path.join(forgeRoot, 'o', 'r.git');
const seed = path.join(root, 'seed');
const upstreamWork = path.join(root, 'upstream-work');
const upstreamRepo = path.join(root, 'upstream.git');
fs.mkdirSync(path.dirname(forgeRepo), { recursive: true });
git(['init', '-b', 'main', seed], root);
const oldSha = commit(seed, 'initial', 'initial');
git(['clone', '--bare', seed, forgeRepo], root);
git(['clone', seed, upstreamWork], root);
const newSha = commit(upstreamWork, 'upstream advance', 'advanced');
git(['clone', '--bare', upstreamWork, upstreamRepo], root);
const config = path.join(root, 'config.json');
fs.writeFileSync(config, JSON.stringify({
url: `file://${forgeRoot}`,
token: TOKEN,
login: 'tester',
}));
return {
root,
forgeRepo,
upstreamWork,
upstreamRepo,
config,
oldSha,
newSha,
cleanup() {
fs.rmSync(root, { recursive: true, force: true });
},
};
}
function refSha(repository, ref) {
const result = spawnSync('git', ['rev-parse', '--verify', ref], {
cwd: repository,
encoding: 'utf8',
});
return result.status === 0 ? result.stdout.trim() : null;
}
function installGitWrapper(fx, body) {
const wrapperDirectory = path.join(fx.root, 'bin');
const wrapper = path.join(wrapperDirectory, 'git');
fs.mkdirSync(wrapperDirectory);
fs.writeFileSync(wrapper, `#!/bin/sh\n${body}\nexec "${REAL_GIT}" "$@"\n`);
fs.chmodSync(wrapper, 0o755);
return { PATH: `${wrapperDirectory}:${process.env.PATH}` };
}
function runSync(fx, extra = [], { branch = 'main', env = {} } = {}) {
const args = [
CLI,
'repo',
'sync',
'-o', 'o',
'-r', 'r',
'--from', `file://${fx.upstreamRepo}`,
];
if (branch) args.push('--branch', branch);
args.push(...extra);
return spawnSync(process.execPath, args, {
encoding: 'utf8',
env: { ...process.env, STOKE_CONFIG_FILE: fx.config, ...env },
});
}
test('repo sync fast-forwards an undiverged forge branch', () => {
const fx = fixture();
try {
const result = runSync(fx);
assert.equal(result.status, 0, result.stderr);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha);
assert.match(result.stdout, new RegExp(`main ${fx.oldSha}\\.\\.${fx.newSha}`));
} finally {
fx.cleanup();
}
});
test('repo sync resolves an omitted branch from the forge symbolic HEAD', () => {
const fx = fixture();
try {
const result = runSync(fx, [], { branch: null });
assert.equal(result.status, 0, result.stderr);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha);
assert.match(result.stdout, new RegExp(`main ${fx.oldSha}\\.\\.${fx.newSha}`));
} finally {
fx.cleanup();
}
});
test('repo sync reports an already-current branch as a no-op', () => {
const fx = fixture();
try {
assert.equal(runSync(fx).status, 0);
const result = runSync(fx);
assert.equal(result.status, 0, result.stderr);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha);
assert.match(result.stdout, new RegExp(`main is up to date at ${fx.newSha}`));
} finally {
fx.cleanup();
}
});
test('repo sync refuses a diverged forge branch without changing it', () => {
const fx = fixture();
try {
const forgeWork = path.join(fx.root, 'forge-work');
git(['clone', fx.forgeRepo, forgeWork], fx.root);
const forgeSha = commit(forgeWork, 'forge-only change', 'forge-only');
git(['push', 'origin', 'main'], forgeWork);
const result = runSync(fx);
assert.equal(result.status, 1);
assert.match(result.stderr, new RegExp(forgeSha));
assert.match(result.stderr, new RegExp(fx.newSha));
assert.match(result.stderr, /Diverged trees are out of scope/);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), forgeSha);
} finally {
fx.cleanup();
}
});
test('repo sync --tags creates new tags but skips a moved upstream tag', () => {
const fx = fixture();
try {
git(['update-ref', 'refs/tags/stable', fx.oldSha], fx.forgeRepo);
git(['update-ref', 'refs/tags/moved', fx.oldSha], fx.forgeRepo);
git(['update-ref', 'refs/tags/stable', fx.oldSha], fx.upstreamRepo);
git(['update-ref', 'refs/tags/moved', fx.newSha], fx.upstreamRepo);
git(['update-ref', 'refs/tags/new-tag', fx.newSha], fx.upstreamRepo);
const result = runSync(fx, ['--tags']);
assert.equal(result.status, 1);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha);
assert.equal(refSha(fx.forgeRepo, 'refs/tags/stable'), fx.oldSha);
assert.equal(refSha(fx.forgeRepo, 'refs/tags/moved'), fx.oldSha);
assert.equal(refSha(fx.forgeRepo, 'refs/tags/new-tag'), fx.newSha);
assert.match(result.stderr, new RegExp(`moved.*${fx.oldSha}.*${fx.newSha}`));
} finally {
fx.cleanup();
}
});
test('repo sync --dry-run reports branch and tag moves without writing', () => {
const fx = fixture();
try {
git(['update-ref', 'refs/tags/new-tag', fx.newSha], fx.upstreamRepo);
const result = runSync(fx, ['--tags', '--dry-run']);
assert.equal(result.status, 0, result.stderr);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.oldSha);
assert.equal(refSha(fx.forgeRepo, 'refs/tags/new-tag'), null);
assert.match(result.stdout, new RegExp(`main ${fx.oldSha}\\.\\.${fx.newSha}`));
assert.match(result.stdout, new RegExp(`new-tag .*${fx.newSha}`));
} finally {
fx.cleanup();
}
});
function runSourceTagRace({ dryRun }) {
const fx = fixture();
git(['update-ref', 'refs/tags/race-tag', fx.oldSha], fx.upstreamRepo);
const env = installGitWrapper(fx, `
case "$*" in
*"refs/tags/race-tag:refs/stoke/upstream-tags/race-tag"*)
"${REAL_GIT}" --git-dir="$STOKE_TEST_UPSTREAM_REPO" update-ref refs/tags/race-tag "$STOKE_TEST_NEW_SHA"
;;
esac`);
Object.assign(env, {
STOKE_TEST_UPSTREAM_REPO: fx.upstreamRepo,
STOKE_TEST_NEW_SHA: fx.newSha,
});
const options = ['--tags'];
if (dryRun) options.push('--dry-run');
return { fx, result: runSync(fx, options, { env }) };
}
test('repo sync reports the fetched tag object when the source tag moves', () => {
const { fx, result } = runSourceTagRace({ dryRun: false });
try {
assert.equal(result.status, 0, result.stderr);
assert.equal(refSha(fx.forgeRepo, 'refs/tags/race-tag'), fx.newSha);
const tagLine = result.stdout.split('\n').find((line) => line.startsWith('tag race-tag'));
assert.match(tagLine, new RegExp(fx.newSha));
assert.ok(!tagLine.includes(fx.oldSha));
} finally {
fx.cleanup();
}
});
test('repo sync --dry-run reports the fetched tag object when the source tag moves', () => {
const { fx, result } = runSourceTagRace({ dryRun: true });
try {
assert.equal(result.status, 0, result.stderr);
assert.equal(refSha(fx.forgeRepo, 'refs/tags/race-tag'), null);
const tagLine = result.stdout.split('\n').find((line) => line.startsWith('tag race-tag'));
assert.match(tagLine, new RegExp(fx.newSha));
assert.ok(!tagLine.includes(fx.oldSha));
} finally {
fx.cleanup();
}
});
test('repo sync reclassifies a destination tag created during the push as moved', () => {
const fx = fixture();
try {
git(['update-ref', 'refs/tags/race-tag', fx.newSha], fx.upstreamRepo);
const env = installGitWrapper(fx, `
case "$*" in
*"refs/stoke/upstream-tags/race-tag:refs/tags/race-tag"*)
"${REAL_GIT}" --git-dir="$STOKE_TEST_FORGE_REPO" update-ref refs/tags/race-tag "$STOKE_TEST_OLD_SHA"
;;
esac`);
Object.assign(env, {
STOKE_TEST_FORGE_REPO: fx.forgeRepo,
STOKE_TEST_OLD_SHA: fx.oldSha,
});
const result = runSync(fx, ['--tags'], { env });
assert.equal(result.status, 1);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha);
assert.equal(refSha(fx.forgeRepo, 'refs/tags/race-tag'), fx.oldSha);
assert.match(result.stderr, new RegExp(`race-tag.*${fx.oldSha}.*${fx.newSha}`));
} finally {
fx.cleanup();
}
});
test('repo sync reports a destination branch that diverges during the push', () => {
const fx = fixture();
try {
const forgeWork = path.join(fx.root, 'forge-race-work');
git(['clone', fx.forgeRepo, forgeWork], fx.root);
const racingSha = commit(forgeWork, 'racing forge change', 'racing-forge');
git(['push', 'origin', 'HEAD:refs/race/forge-only'], forgeWork);
const env = installGitWrapper(fx, `
case "$*" in
*"refs/stoke/upstream-branch:refs/heads/main"*)
"${REAL_GIT}" --git-dir="$STOKE_TEST_FORGE_REPO" update-ref refs/heads/main "$STOKE_TEST_RACING_SHA"
;;
esac`);
Object.assign(env, {
STOKE_TEST_FORGE_REPO: fx.forgeRepo,
STOKE_TEST_RACING_SHA: racingSha,
});
const result = runSync(fx, [], { env });
assert.equal(result.status, 1);
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), racingSha);
assert.match(result.stderr, new RegExp(racingSha));
assert.match(result.stderr, new RegExp(fx.newSha));
assert.match(result.stderr, /Diverged trees are out of scope/);
} finally {
fx.cleanup();
}
});
test('repo sync keeps the token out of Git argv, output, remotes, and config', () => {
const fx = fixture();
try {
const argvLog = path.join(fx.root, 'git-argv.log');
const localConfigLog = path.join(fx.root, 'git-local-config.log');
const env = installGitWrapper(fx, `
printf '%s\\n' "$@" >> "$STOKE_TEST_GIT_ARGV"
if [ -f "$PWD/config" ]; then
sed -n '1,240p' "$PWD/config" >> "$STOKE_TEST_LOCAL_CONFIG"
"${REAL_GIT}" config --local --get-regexp '^remote\\..*\\.url$' >> "$STOKE_TEST_LOCAL_CONFIG" 2>/dev/null || true
fi`);
Object.assign(env, {
STOKE_TEST_GIT_ARGV: argvLog,
STOKE_TEST_LOCAL_CONFIG: localConfigLog,
});
const result = runSync(fx, [], { env });
assert.equal(result.status, 0, result.stderr);
for (const text of [
result.stdout,
result.stderr,
fs.readFileSync(argvLog, 'utf8'),
fs.readFileSync(localConfigLog, 'utf8'),
fs.readFileSync(path.join(fx.forgeRepo, 'config'), 'utf8'),
fs.readFileSync(path.join(fx.upstreamRepo, 'config'), 'utf8'),
]) {
assert.ok(!text.includes(TOKEN), 'token leaked from the environment-only auth path');
assert.ok(!text.includes(BASIC_CREDENTIAL), 'encoded credential leaked from the environment-only auth path');
}
} finally {
fx.cleanup();
}
});