Compare commits

...

59 commits
v1.4.0 ... main

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
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
34 changed files with 1909 additions and 96 deletions

View file

@ -2,12 +2,13 @@
Never edit these files ad hoc. The six manifest-listed doctrine files are Never edit these files ad hoc. The six manifest-listed doctrine files are
byte-identical copies of byte-identical copies of
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at 0.6.3, 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 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. 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.3 The labels doctrine is vendored manually from
because stoke keeps its own tag-driven `.forgejo/workflows/release.yml`. [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 `docs-sync --fix` cannot run until or unless a future issue adopts the
ceremony release-workflow pin; until then, doctrine updates must re-vendor ceremony release-workflow pin; until then, doctrine updates must re-vendor
the pinned manifest manually. the pinned manifest manually.

View file

@ -5,9 +5,11 @@
# Requirements: # Requirements:
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label # - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
# your runner actually advertises (common: docker, ubuntu-latest). # your runner actually advertises (common: docker, ubuntu-latest).
# - A repository/org secret RELEASE_TOKEN: a token with package:write and # - A repository/org secret RELEASE_TOKEN: a token with read:user,
# repository:write scopes for an account allowed to publish packages # write:package and write:repository scopes for an account allowed to
# under the heavy-duty org. # 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 name: release
on: on:
@ -37,20 +39,5 @@ jobs:
- name: Create release and attach .deb - name: Create release and attach .deb
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }} run: bash scripts/publish-release.sh "${{ github.ref_name }}" "$(node -p "require('./package.json').version")" "$(ls dist/stoke_*_all.deb)" "${{ github.repository_owner }}" stoke
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"

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/**"] - any-glob-to-any-file: ["src/**"]
"scope:packaging": "scope:packaging":
- changed-files: - 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": "scope:manifests":
- changed-files: - changed-files:
- any-glob-to-any-file: ["manifests/**"] - any-glob-to-any-file: ["manifests/**"]
"scope:ci": "scope:ci":
- changed-files: - changed-files:
- any-glob-to-any-file: [".forgejo/workflows/**"] - any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"]
"scope:docs": "scope:docs":
- changed-files: - 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 panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
triage-actors=claude-bot-andresmgsl triage-actors=claude-bot-andresmgsl
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js) 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:manifests|C5DEF5|manifests/ — the fleet repo registry data
scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates scope:ci|C5DEF5|.forgejo/workflows/, .github/, and .ceremony/ — the test, label, and governance gates
scope:docs|C5DEF5|README and docs/ — the prose contract scope:docs|C5DEF5|root Markdown and docs/ — the prose contract

View file

@ -1,7 +1,7 @@
# AGENTS.md — start at .ceremony/ # AGENTS.md — start at .ceremony/
This repository is governed by 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 `.ceremony/AGENTS.md` first — it routes you to your role file, vendored
beside it. Repo specifics (the review panel roster, the scope labels, what beside it. Repo specifics (the review panel roster, the scope labels, what
a drill means here, code conventions) live in CONTRIBUTING.md. a drill means here, code conventions) live in CONTRIBUTING.md.

View file

@ -1,5 +1,22 @@
# Changelog # 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 ## 1.4.0 — 2026-08-31
### Added ### Added

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.

View file

@ -126,8 +126,8 @@ Example stored config:
```json ```json
{ {
"url": "https://forgejo.heavyduty.builders", "url": "https://forgejo.heavyduty.builders",
"login": "kimi-reviewer-andresmgsl", "login": "kimi-bot-andresmgsl",
"username": "kimi-reviewer-andresmgsl", "username": "kimi-bot-andresmgsl",
"email": "andres+4@heavyduty.builders", "email": "andres+4@heavyduty.builders",
"token": "<sha1>", "token": "<sha1>",
"tokenId": 42 "tokenId": 42
@ -187,7 +187,7 @@ stoke auth login
Non-interactive example using environment variables: Non-interactive example using environment variables:
```bash ```bash
export STOKE_USERNAME='kimi-reviewer-andresmgsl' export STOKE_USERNAME='kimi-bot-andresmgsl'
export STOKE_PASSWORD='...' export STOKE_PASSWORD='...'
stoke auth login stoke auth login
``` ```
@ -196,7 +196,7 @@ Password file example (avoids shell history and special-character issues):
```bash ```bash
chmod 600 /run/secrets/stoke-password 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: Existing token example:
@ -291,6 +291,31 @@ 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`. 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` ### `stoke repo create`
Create a new repository for the authenticated user or an organization. Create a new repository for the authenticated user or an organization.
@ -447,7 +472,7 @@ Options:
Example used to move the heavy-duty repositories into the `heavy-duty` organization: Example used to move the heavy-duty repositories into the `heavy-duty` organization:
```bash ```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`. Calls `POST /api/v1/repos/{owner}/{repo}/transfer`.
@ -466,7 +491,7 @@ Options:
``` ```
```bash ```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. Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
@ -545,7 +570,7 @@ Options:
``` ```
```bash ```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. Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
@ -828,7 +853,7 @@ Options:
``` ```
```bash ```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. Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
@ -848,8 +873,8 @@ Options:
Example: Example:
```bash ```bash
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u andres --permission admin stoke collaborator add -o kimi-bot-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 dan --permission admin
``` ```
Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`. Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`.
@ -985,7 +1010,7 @@ Options:
``` ```
```bash ```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}`. Calls `DELETE /api/v1/teams/{id}/members/{username}`.
@ -1107,7 +1132,7 @@ Release checklist:
## Verification: heavy-duty repository imports ## 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 | | Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

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", "name": "stoke",
"version": "1.3.0", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "stoke", "name": "stoke",
"version": "1.3.0", "version": "1.5.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"commander": "^15.0.0" "commander": "^15.0.0"

View file

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

View file

@ -67,9 +67,9 @@ EOF
# Native package (no Debian revision in the version), so plain changelog.gz. # Native package (no Debian revision in the version), so plain changelog.gz.
gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz" gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz"
# Normalize permissions regardless of the builder's umask: no group/other # Normalize permissions regardless of the builder's umask: traversable
# write anywhere, executable entry point. # directories, readable files, and execute bits retained only where intended.
chmod -R go-w "$PKG/usr" chmod -R u+rwX,go=rX "$PKG/usr"
chmod 0755 "$LIB/src/cli.js" chmod 0755 "$LIB/src/cli.js"
# --- control ----------------------------------------------------------------- # --- 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 fs = require('node:fs');
const path = require('node:path'); 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) { function parseArgs(argv) {
const options = { const options = {
@ -55,6 +62,65 @@ function parseConfig(contents, filename) {
return { identities, scopes }; 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) { async function validateIdentities(apiUrl, identities) {
for (const login of identities) { for (const login of identities) {
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`; const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
@ -72,6 +138,10 @@ async function main() {
const options = parseArgs(process.argv.slice(2)); const options = parseArgs(process.argv.slice(2));
const contents = fs.readFileSync(options.config, 'utf8'); const contents = fs.readFileSync(options.config, 'utf8');
const { identities, scopes } = parseConfig(contents, options.config); 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); await validateIdentities(options.apiUrl, identities);
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`); console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
} }

View file

@ -9,7 +9,7 @@
# component APT component, default: main # component APT component, default: main
# #
# Authentication (first match wins): # 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` # 2. The token stored by `stoke auth login`
# #
# The Forgejo URL defaults to the instance in the stoke config, falling back # 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")}" 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")}" 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" URL="$FORGE_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
echo "Uploading $(basename "$DEB") to $URL" echo "Uploading $(basename "$DEB") to $URL"
STATUS="$(curl -sS -o /tmp/stoke-publish-response.$$ -w '%{http_code}' \ STATUS="$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X PUT -H "Authorization: token $TOKEN" \ -X PUT -H @"$HEADER_FILE" \
--upload-file "$DEB" "$URL")" --upload-file "$DEB" "$URL")"
case "$STATUS" in case "$STATUS" in
@ -44,9 +65,7 @@ case "$STATUS" in
409) echo "Already published (409): this exact version already exists in the registry." ;; 409) echo "Already published (409): this exact version already exists in the registry." ;;
*) *)
echo "error: upload failed with HTTP $STATUS" >&2 echo "error: upload failed with HTTP $STATUS" >&2
cat /tmp/stoke-publish-response.$$ >&2 || true cat "$RESPONSE_FILE" >&2 || true
rm -f /tmp/stoke-publish-response.$$
exit 1 exit 1
;; ;;
esac 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

@ -8,6 +8,7 @@ const { execSync, spawnSync } = require('node:child_process');
const { stdin: input, stdout: output } = require('node:process'); const { stdin: input, stdout: output } = require('node:process');
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config'); const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
const { ForgejoClient } = require('./api'); const { ForgejoClient } = require('./api');
const { syncRepository } = require('./repo-sync');
const pkg = require('../package.json'); const pkg = require('../package.json');
@ -294,6 +295,8 @@ auth
} else { } 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.`); 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(); clearConfig();
@ -312,8 +315,12 @@ auth
try { try {
const config = loadConfig(); const config = loadConfig();
if (!config || !config.token) { if (!config || !config.token) {
if (options.json) {
console.log('{"authenticated": false}');
} else {
console.log('Not authenticated.'); console.log('Not authenticated.');
return; }
process.exit(1);
} }
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
@ -451,6 +458,49 @@ 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 repo
.command('create') .command('create')
.description('Create a new repository for the authenticated user or an organization') .description('Create a new repository for the authenticated user or an organization')
@ -605,6 +655,7 @@ repo
continue; continue;
} }
try {
const service = item.service || 'github'; const service = item.service || 'github';
const isPrivate = item.public ? false : Boolean(item.private); const isPrivate = item.public ? false : Boolean(item.private);
const payload = { const payload = {
@ -628,7 +679,6 @@ repo
if (payload[key] === undefined) delete payload[key]; if (payload[key] === undefined) delete payload[key];
}); });
try {
const result = await client.migrateRepo(payload); const result = await client.migrateRepo(payload);
console.log(`Imported: ${result.full_name} -> ${result.html_url}`); console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
results.push({ name, status: 'ok', url: result.html_url }); results.push({ name, status: 'ok', url: result.html_url });

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 };

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. // "Not authenticated" instead of silently using the default config.
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`); const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
const res = run(['--config', missing, 'auth', 'status']); const res = run(['--config', missing, 'auth', 'status']);
assert.equal(res.status, 0); assert.equal(res.status, 1);
assert.match(res.stdout, /Not authenticated/); 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', () => { test('invalid --limit is rejected before any network call', () => {
const res = run(['repo', 'list', '-l', 'abc']); const res = run(['repo', 'list', '-l', 'abc']);
assert.equal(res.status, 1); assert.equal(res.status, 1);

View file

@ -1,6 +1,7 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs'); const fs = require('node:fs');
const http = require('node:http'); const http = require('node:http');
const os = require('node:os'); 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_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony'); const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md'); 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 = []; const cleanups = [];
process.on('exit', () => { process.on('exit', () => {
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
@ -21,12 +27,30 @@ function writeConfig(contents) {
cleanups.push(dir); cleanups.push(dir);
const config = path.join(dir, 'labels.conf'); const config = path.join(dir, 'labels.conf');
fs.writeFileSync(config, contents); fs.writeFileSync(config, contents);
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
return config; 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) => { return new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], { const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
cwd,
encoding: 'utf8', encoding: 'utf8',
}); });
let stdout = ''; let stdout = '';
@ -106,6 +130,71 @@ test('governance validator rejects malformed scope rows before identity requests
assert.doesNotMatch(result.stderr, /fetch failed/); 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 () => { 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']); const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
await withIdentityServer(logins, async (apiUrl) => { 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 labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
const expected = { const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
'scope:cli': ['src/**'], .flatMap((match) => JSON.parse(match[1]));
'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'], const tracked = execFileSync('git', ['ls-files'], {
'scope:manifests': ['manifests/**'], cwd: path.join(__dirname, '..'),
'scope:ci': ['.forgejo/workflows/**'], encoding: 'utf8',
'scope:docs': ['README.md', 'docs/**'], }).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.deepEqual(uncovered, []);
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}`);
}
}); });
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']; const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
for (const filename of vendored) { for (const filename of vendored) {
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`); 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 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.match(mirrorReadme, /labels doctrine is vendored manually/);
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/); assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/); 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 });
}
});

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();
}
});