Compare commits

...

89 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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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
47 changed files with 2965 additions and 171 deletions

View file

@ -14,7 +14,7 @@ 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 discussions into buildable issues — or refuse well; you are the only door issues come through |
| **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 |
@ -23,7 +23,7 @@ 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 discussions or the
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.
@ -31,13 +31,13 @@ pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints issues.** Found work? Open or extend a discussion.
- **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.

View file

@ -33,7 +33,10 @@ triage bug, and the move is to say so on the issue, not to guess.
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;
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;
@ -137,8 +140,8 @@ triage bug, and the move is to say so on the issue, not to guess.
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 discussion, or a comment on the relevant issue;
you do not mint issues — nobody but triage does — and you do not fix
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
@ -174,7 +177,9 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
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. Cancelled or stale is not green, *stale* being a
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
@ -200,8 +205,19 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
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. Prefer verification over argument — add the test that settles
the doubt.
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
@ -218,8 +234,9 @@ 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, and `blocker:unrequested` does not fire while a
head's checks are pending or red (#236).
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

View file

@ -27,8 +27,10 @@ and the reconciler recomputes it from GitHub's own facts.
`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); the `labeled` event fires the sweep that validates the
write within seconds.
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)
@ -49,7 +51,7 @@ strips it on sight).
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
| `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 |
@ -240,4 +242,4 @@ 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` is a discussion, not an issue.
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.

View file

@ -2,12 +2,13 @@
Never edit these files ad hoc. The six manifest-listed doctrine files are
byte-identical copies of
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at 0.6.1, but
[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 at 0.6.1
because stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
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.

View file

@ -40,6 +40,59 @@ decision during release-init. The double gate on
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
@ -54,7 +107,9 @@ steps:
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`.
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.
@ -74,18 +129,50 @@ 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 with a non-empty
enumerated gate — 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; and the `ready` set is exactly the graph's current sources. Every close
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 gate member. `epic` and `post-merge` issues are exempt
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
@ -104,6 +191,13 @@ 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.

View file

@ -123,9 +123,9 @@ saw Y" outranks one that says "this looks like it might".
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a discussion where humans had their say. If you think the
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
discussion, while still reviewing the implementation against the spec as
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.

View file

@ -1,22 +1,25 @@
# TRIAGE.md — the triage role
You are the only door issues come through. Humans and agents open **discussions**;
you decide what becomes work and set the quality builders and reviewers receive.
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
Discussions may be ambiguous; issues may not: a builder must be able to execute
one **without asking anything**. One accountable role keeps builders from guessing.
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 discussion** in the repo you serve.
- **Stray issues** — anything filed directly, by anyone. Label it
- **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 back into a discussion and close it, saying why. Route the work
substance into a proposal and close it, saying why. Route the work
without shaming the filer.
## For each discussion, converge on exactly one outcome
## 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.
@ -32,9 +35,9 @@ one **without asking anything**. One accountable role keeps builders from guessi
[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 discussion is where humans decide; wait there. When the decision
The proposal is where humans decide; wait there. When the decision
blocks something already on the board — an existing issue, or minted work
a discussion's ruling gates — set `needs-ruling` on it too, so the board
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
@ -61,7 +64,7 @@ one **without asking anything**. One accountable role keeps builders from guessi
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 discussion is not.
outcome; a zombie proposal is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
@ -71,7 +74,7 @@ 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 discussion it came from,
- **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
@ -106,9 +109,11 @@ Every issue you mint carries, in this order:
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 the new issue to its gate, recording
membership only. Collision and window edges are independent, so write both
when both apply (#292).
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)).
@ -157,4 +162,4 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
- 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 an issue to "discuss" something — that is a discussion.
- Mint a work issue to explore an idea — file a proposal instead.

View file

@ -34,7 +34,7 @@ permissions:
pull-requests: write
jobs:
sweep:
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@0.6.1
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
@ -46,4 +46,4 @@ jobs:
# 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).
# the caller", which no PR edit can do (#208 reads it).

View file

@ -28,7 +28,7 @@ permissions:
pull-requests: write
jobs:
labels:
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.6.1
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).
# 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

6
.github/labeler.yml vendored
View file

@ -4,13 +4,13 @@
- any-glob-to-any-file: ["src/**"]
"scope:packaging":
- changed-files:
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml"]
- 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/**"]
- any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"]
"scope:docs":
- changed-files:
- any-glob-to-any-file: ["README.md", "docs/**"]
- any-glob-to-any-file: ["*.md", "docs/**"]

6
.github/labels.conf vendored
View file

@ -1,7 +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/ and the release workflow — deb build, registry publish, apt install path
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/ — the test and label gates
scope:docs|C5DEF5|README and docs/ — the prose contract
scope:ci|C5DEF5|.forgejo/workflows/, .github/, and .ceremony/ — the test, label, and governance gates
scope:docs|C5DEF5|root Markdown and docs/ — the prose contract

View file

@ -1,7 +1,7 @@
# AGENTS.md — start at .ceremony/
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
[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.

148
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
@ -141,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
```
@ -150,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:
@ -245,13 +291,39 @@ 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
@ -265,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`
@ -396,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`.
@ -415,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.
@ -494,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.
@ -620,7 +696,8 @@ Calls `GET /api/v1/repos/{owner}/{repo}/releases` and auto-paginates.
### `stoke release view`
Show the release for a tag, including its notes.
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:
@ -648,15 +725,46 @@ Options:
-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
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
```
Calls `POST /api/v1/repos/{owner}/{repo}/releases`.
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`
@ -745,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.
@ -765,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}`.
@ -902,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}`.
@ -1024,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 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

View file

@ -1 +0,0 @@
- Adopted ceremony label and review automation with a machine-readable panel and scope taxonomy. (#30).

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).

4
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{
"name": "stoke",
"version": "1.3.0",
"version": "1.5.0",
"description": "CLI for the heavy-duty forge (Forgejo)",
"main": "src/cli.js",
"scripts": {

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"

View file

@ -2,6 +2,13 @@
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 = {
@ -55,6 +62,65 @@ function parseConfig(contents, filename) {
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)}`;
@ -72,6 +138,10 @@ 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`);
}

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,8 +108,8 @@ 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
@ -94,7 +122,7 @@ $SUDO chmod 0644 "$KEYRING" "$LIST"
# 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 [ "$(curl -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then
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
@ -102,16 +130,37 @@ if [ "$(curl -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404
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,8 +168,14 @@ class ForgejoClient {
return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
}
async createRepo(payload) {
return this.post('/user/repos', 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) {
@ -223,6 +274,22 @@ class ForgejoClient {
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);
}

View file

@ -3,10 +3,12 @@
const { Command, InvalidArgumentError } = require('commander');
const readline = require('node:readline');
const fs = require('node:fs');
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');
@ -92,6 +94,36 @@ function parseDepth(value) {
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) {
@ -263,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();
@ -281,8 +315,12 @@ auth
try {
const config = loadConfig();
if (!config || !config.token) {
console.log('Not authenticated.');
return;
if (options.json) {
console.log('{"authenticated": false}');
} else {
console.log('Not authenticated.');
}
process.exit(1);
}
const client = ForgejoClient.fromConfig(config);
@ -420,10 +458,54 @@ repo
}
});
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')
@ -444,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}`);
@ -573,30 +655,30 @@ repo
continue;
}
const service = item.service || 'github';
const isPrivate = item.public ? false : Boolean(item.private);
const payload = {
clone_addr: from,
repo_name: name,
repo_owner: item.owner || item.repo_owner || config.login,
service,
description: item.description || undefined,
private: isPrivate,
issues: normalizeBool(item.issues, true),
labels: normalizeBool(item.labels, true),
milestones: normalizeBool(item.milestones, true),
pull_requests: normalizeBool(item.pull_requests, true),
releases: normalizeBool(item.releases, true),
wiki: normalizeBool(item.wiki, true),
lfs: normalizeBool(item.lfs, false),
auth_token: resolveSourceToken(item.github_token, service),
};
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined) delete payload[key];
});
try {
const service = item.service || 'github';
const isPrivate = item.public ? false : Boolean(item.private);
const payload = {
clone_addr: from,
repo_name: name,
repo_owner: item.owner || item.repo_owner || config.login,
service,
description: item.description || undefined,
private: isPrivate,
issues: normalizeBool(item.issues, true),
labels: normalizeBool(item.labels, true),
milestones: normalizeBool(item.milestones, true),
pull_requests: normalizeBool(item.pull_requests, true),
releases: normalizeBool(item.releases, true),
wiki: normalizeBool(item.wiki, true),
lfs: normalizeBool(item.lfs, false),
auth_token: resolveSourceToken(item.github_token, service),
};
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined) delete payload[key];
});
const result = await client.migrateRepo(payload);
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
results.push({ name, status: 'ok', url: result.html_url });
@ -1043,6 +1125,12 @@ release
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);
}
@ -1063,10 +1151,13 @@ release
.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 = {
@ -1079,7 +1170,22 @@ release
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}`);
@ -1087,6 +1193,39 @@ release
}
});
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)) {

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');
@ -243,6 +283,62 @@ test('release endpoints map to the expected URLs and payloads', async () => {
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');

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

@ -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);

View file

@ -1,6 +1,7 @@
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');
@ -11,6 +12,11 @@ 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 });
@ -21,12 +27,30 @@ function writeConfig(contents) {
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 runValidator(config, apiUrl) {
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 = '';
@ -106,6 +130,71 @@ test('governance validator rejects malformed scope rows before identity requests
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) => {
@ -115,30 +204,66 @@ test('repository governance config resolves the current four-member panel and fi
});
});
test('repository scope mapping covers every configured scope with the ruled paths', () => {
test('repository scope mapping covers every tracked path except the ruled residue', () => {
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
const expected = {
'scope:cli': ['src/**'],
'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'],
'scope:manifests': ['manifests/**'],
'scope:ci': ['.forgejo/workflows/**'],
'scope:docs': ['README.md', 'docs/**'],
};
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))
));
for (const [label, globs] of Object.entries(expected)) {
assert.match(labeler, new RegExp(`^"${label}":`, 'm'), `${label} has no mapping`);
for (const glob of globs) assert.ok(labeler.includes(JSON.stringify(glob)), `${label} does not map ${glob}`);
}
assert.deepEqual(uncovered, []);
});
test('repository carries the complete 0.6.1 doctrine mirror and root router', () => {
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/);
assert.match(fs.readFileSync(ROOT_AGENTS, 'utf8'), /read\s+`.ceremony\/AGENTS\.md` first/i);
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

@ -13,12 +13,16 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh');
// 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, releaseStatus }) {
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');
@ -26,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);
@ -39,10 +43,30 @@ 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('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',
@ -58,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',
@ -82,6 +114,10 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
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
},
});
@ -96,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.
@ -171,3 +210,118 @@ test('registry Release file present: proceeds with the install', () => {
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();
}
});