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
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
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
because stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
The labels doctrine is vendored manually from
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony) at 0.6.3 because
stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
`docs-sync --fix` cannot run until or unless a future issue adopts the
ceremony release-workflow pin; until then, doctrine updates must re-vendor
the pinned manifest manually.

View file

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

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

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

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

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

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

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

6
.github/labeler.yml vendored
View file

@ -4,13 +4,13 @@
- any-glob-to-any-file: ["src/**"]
"scope:packaging":
- changed-files:
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml"]
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml", "package.json", "package-lock.json", "CHANGELOG.md", "changelog.d/**"]
"scope:manifests":
- changed-files:
- any-glob-to-any-file: ["manifests/**"]
"scope:ci":
- changed-files:
- any-glob-to-any-file: [".forgejo/workflows/**"]
- any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"]
"scope:docs":
- changed-files:
- any-glob-to-any-file: ["README.md", "docs/**"]
- any-glob-to-any-file: ["*.md", "docs/**"]

6
.github/labels.conf vendored
View file

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

View file

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

View file

@ -1,5 +1,22 @@
# Changelog
## 1.5.0 — 2026-09-02
### Added
- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23).
- Added guided proposal and triage work-order forms, with the contributor guide linking directly to proposal intake. (#50).
### Changed
- Enforced the exact Forgejo ceremony source and version across governance records and workflow pins. (#36).
- Publish release assets and the matching changelog section through stoke's credential-safe CLI. (#54).
### Fixed
- Keep package-lock metadata aligned with the package version and guard against future drift. (#43).
- Cover every governed repository surface and reject unmapped tracked paths or divergent scope names. (#48).
## 1.4.0 — 2026-08-31
### Added

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
{
"url": "https://forgejo.heavyduty.builders",
"login": "kimi-reviewer-andresmgsl",
"username": "kimi-reviewer-andresmgsl",
"login": "kimi-bot-andresmgsl",
"username": "kimi-bot-andresmgsl",
"email": "andres+4@heavyduty.builders",
"token": "<sha1>",
"tokenId": 42
@ -187,7 +187,7 @@ stoke auth login
Non-interactive example using environment variables:
```bash
export STOKE_USERNAME='kimi-reviewer-andresmgsl'
export STOKE_USERNAME='kimi-bot-andresmgsl'
export STOKE_PASSWORD='...'
stoke auth login
```
@ -196,7 +196,7 @@ Password file example (avoids shell history and special-character issues):
```bash
chmod 600 /run/secrets/stoke-password
stoke auth login -n kimi-reviewer-andresmgsl --password-file /run/secrets/stoke-password
stoke auth login -n kimi-bot-andresmgsl --password-file /run/secrets/stoke-password
```
Existing token example:
@ -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`.
### `stoke repo sync`
Fast-forward an undiverged imported repository from an upstream Git URL.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--from <upstream-url> upstream Git URL (required)
--branch <branch> branch to synchronize (default: the forge repository's default branch)
--tags also create new upstream tags
--dry-run report branch and tag moves without pushing
```
```bash
stoke repo sync -o heavy-duty -r box \
--from https://github.com/heavy-duty/box.git
stoke repo sync -o heavy-duty -r box \
--from https://github.com/heavy-duty/box.git --tags --dry-run
```
The command fetches both branch tips into an ephemeral bare repository and pushes only when the forge tip is an ancestor of the upstream tip. It refuses a diverged tree with both commit SHAs and never offers a force option. With `--tags`, new upstream tags are created; an existing forge tag that points elsewhere is reported and left untouched, and the command exits non-zero after applying any other safe moves. The stored Forgejo token uses the same environment-only Git authentication as `repo clone` and is never written to an argument, remote, or Git config.
This verb deliberately does not merge diverged trees, configure Forgejo pull-mirrors, or copy releases. Follow ceremony's live `docs/UPSTREAM-SYNC.md` procedure for a diverged tree; import a scheduled read-only repository as a pull-mirror; compose release mirroring from `release create` and `release upload`.
### `stoke repo create`
Create a new repository for the authenticated user or an organization.
@ -447,7 +472,7 @@ Options:
Example used to move the heavy-duty repositories into the `heavy-duty` organization:
```bash
stoke repo transfer -o kimi-reviewer-andresmgsl -r box --to heavy-duty
stoke repo transfer -o kimi-bot-andresmgsl -r box --to heavy-duty
```
Calls `POST /api/v1/repos/{owner}/{repo}/transfer`.
@ -466,7 +491,7 @@ Options:
```
```bash
stoke issue list -o kimi-reviewer-andresmgsl -r box -s all -l 0
stoke issue list -o kimi-bot-andresmgsl -r box -s all -l 0
```
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
@ -545,7 +570,7 @@ Options:
```
```bash
stoke pr list -o kimi-reviewer-andresmgsl -r box -s all -l 0
stoke pr list -o kimi-bot-andresmgsl -r box -s all -l 0
```
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
@ -828,7 +853,7 @@ Options:
```
```bash
stoke branch list -o kimi-reviewer-andresmgsl -r box
stoke branch list -o kimi-bot-andresmgsl -r box
```
Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
@ -848,8 +873,8 @@ Options:
Example:
```bash
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u andres --permission admin
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u dan --permission admin
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u andres --permission admin
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u dan --permission admin
```
Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`.
@ -985,7 +1010,7 @@ Options:
```
```bash
stoke org team member-remove --team-id 1 -u kimi-reviewer-andresmgsl
stoke org team member-remove --team-id 1 -u kimi-bot-andresmgsl
```
Calls `DELETE /api/v1/teams/{id}/members/{username}`.
@ -1107,7 +1132,7 @@ Release checklist:
## Verification: heavy-duty repository imports
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-bot-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
| Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

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

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

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

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

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

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

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

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

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

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

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

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

4
package-lock.json generated
View file

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

View file

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

View file

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

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

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

View file

@ -2,6 +2,13 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const UNSCOPED_PATHS = [
'.gitignore', // Repository plumbing has no product surface.
'assets/logo-mark.svg', // One legacy brand asset does not justify a scope taxonomy.
'test/*.test.js', // Tests inherit the scope of the production surface changed beside them.
];
function parseArgs(argv) {
const options = {
@ -55,6 +62,65 @@ function parseConfig(contents, filename) {
return { identities, scopes };
}
function parseLabeler(contents, filename) {
const mappings = new Map();
let current;
for (const line of contents.split(/\r?\n/)) {
const label = line.match(/^"([^"]+)":$/);
if (label) {
current = label[1];
if (mappings.has(current)) throw new Error(`duplicate scope mapping in ${filename}: ${current}`);
mappings.set(current, []);
continue;
}
const matcher = line.match(/^\s+- any-glob-to-any-file:\s*(\[[^\n]+\])$/);
if (!matcher) continue;
if (!current) throw new Error(`scope matcher has no label in ${filename}`);
let globs;
try {
globs = JSON.parse(matcher[1]);
} catch {
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
}
if (!Array.isArray(globs) || globs.length === 0 || globs.some((glob) => typeof glob !== 'string')) {
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
}
mappings.get(current).push(...globs);
}
for (const [label, globs] of mappings) {
if (globs.length === 0) throw new Error(`scope mapping has no globs in ${filename}: ${label}`);
}
return mappings;
}
function validateScopeNames(scopes, mappings) {
const configNames = new Set(scopes.map((scope) => scope.split('|', 1)[0]));
const labelerNames = new Set(mappings.keys());
const onlyInConfig = [...configNames].filter((name) => !labelerNames.has(name)).sort();
const onlyInLabeler = [...labelerNames].filter((name) => !configNames.has(name)).sort();
const errors = [];
if (onlyInConfig.length > 0) errors.push(`scope names only in labels.conf: ${onlyInConfig.join(', ')}`);
if (onlyInLabeler.length > 0) errors.push(`scope names only in labeler.yml: ${onlyInLabeler.join(', ')}`);
if (errors.length > 0) throw new Error(errors.join('; '));
}
function validateTrackedPaths(mappings) {
let tracked;
try {
tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
} catch (error) {
throw new Error(`cannot enumerate tracked paths: ${error.message}`);
}
const globs = [...mappings.values()].flat();
const uncovered = tracked.filter((filename) => (
!UNSCOPED_PATHS.some((glob) => path.matchesGlob(filename, glob))
&& !globs.some((glob) => path.matchesGlob(filename, glob))
));
if (uncovered.length > 0) {
throw new Error(`tracked paths have no scope mapping: ${uncovered.join(', ')}`);
}
}
async function validateIdentities(apiUrl, identities) {
for (const login of identities) {
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
@ -72,6 +138,10 @@ async function main() {
const options = parseArgs(process.argv.slice(2));
const contents = fs.readFileSync(options.config, 'utf8');
const { identities, scopes } = parseConfig(contents, options.config);
const labeler = path.join(path.dirname(options.config), 'labeler.yml');
const mappings = parseLabeler(fs.readFileSync(labeler, 'utf8'), labeler);
validateScopeNames(scopes, mappings);
validateTrackedPaths(mappings);
await validateIdentities(options.apiUrl, identities);
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
}

View file

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

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

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

View file

@ -8,6 +8,7 @@ const { execSync, spawnSync } = require('node:child_process');
const { stdin: input, stdout: output } = require('node:process');
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
const { ForgejoClient } = require('./api');
const { syncRepository } = require('./repo-sync');
const pkg = require('../package.json');
@ -294,6 +295,8 @@ auth
} else {
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
}
} else if (!config.tokenId && !options.localOnly) {
console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`);
}
clearConfig();
@ -312,8 +315,12 @@ auth
try {
const config = loadConfig();
if (!config || !config.token) {
console.log('Not authenticated.');
return;
if (options.json) {
console.log('{"authenticated": false}');
} else {
console.log('Not authenticated.');
}
process.exit(1);
}
const client = ForgejoClient.fromConfig(config);
@ -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
.command('create')
.description('Create a new repository for the authenticated user or an organization')
@ -605,30 +655,30 @@ repo
continue;
}
const service = item.service || 'github';
const isPrivate = item.public ? false : Boolean(item.private);
const payload = {
clone_addr: from,
repo_name: name,
repo_owner: item.owner || item.repo_owner || config.login,
service,
description: item.description || undefined,
private: isPrivate,
issues: normalizeBool(item.issues, true),
labels: normalizeBool(item.labels, true),
milestones: normalizeBool(item.milestones, true),
pull_requests: normalizeBool(item.pull_requests, true),
releases: normalizeBool(item.releases, true),
wiki: normalizeBool(item.wiki, true),
lfs: normalizeBool(item.lfs, false),
auth_token: resolveSourceToken(item.github_token, service),
};
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined) delete payload[key];
});
try {
const service = item.service || 'github';
const isPrivate = item.public ? false : Boolean(item.private);
const payload = {
clone_addr: from,
repo_name: name,
repo_owner: item.owner || item.repo_owner || config.login,
service,
description: item.description || undefined,
private: isPrivate,
issues: normalizeBool(item.issues, true),
labels: normalizeBool(item.labels, true),
milestones: normalizeBool(item.milestones, true),
pull_requests: normalizeBool(item.pull_requests, true),
releases: normalizeBool(item.releases, true),
wiki: normalizeBool(item.wiki, true),
lfs: normalizeBool(item.lfs, false),
auth_token: resolveSourceToken(item.github_token, service),
};
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined) delete payload[key];
});
const result = await client.migrateRepo(payload);
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
results.push({ name, status: 'ok', url: result.html_url });

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.
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
const res = run(['--config', missing, 'auth', 'status']);
assert.equal(res.status, 0);
assert.equal(res.status, 1);
assert.match(res.stdout, /Not authenticated/);
});
test('auth status reports an absent session in text and JSON with a failing status', () => {
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}-auth-status.json`);
const text = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing });
assert.equal(text.status, 1);
assert.equal(text.stdout, 'Not authenticated.\n');
assert.equal(text.stderr, '');
const json = run(['auth', 'status', '--json'], { STOKE_CONFIG_FILE: missing });
assert.equal(json.status, 1);
assert.equal(json.stdout, '{"authenticated": false}\n');
assert.equal(json.stderr, '');
});
test('auth logout identifies a supplied token that remains active without changing local-only output', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-auth-logout-'));
const cfg = path.join(dir, 'config.json');
const config = {
url: 'https://forge.test',
login: 'bot',
username: 'bot',
token: 'token-that-must-not-be-printed',
tokenId: null,
};
try {
fs.writeFileSync(cfg, JSON.stringify(config));
const logout = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg });
assert.equal(logout.status, 0, logout.stderr);
assert.match(logout.stdout, /local credentials/i);
assert.match(logout.stdout, /did not create this token/i);
assert.match(logout.stdout, /cannot revoke it/i);
assert.match(logout.stdout, /still valid on https:\/\/forge\.test/i);
assert.match(logout.stdout, /Settings > Applications/);
assert.doesNotMatch(logout.stdout, /Revoked token/);
assert.doesNotMatch(logout.stdout, /Password for/);
assert.doesNotMatch(logout.stdout, /token-that-must-not-be-printed/);
assert.equal(fs.existsSync(cfg), false);
fs.writeFileSync(cfg, JSON.stringify(config));
const localOnly = run(['auth', 'logout', '--local-only'], { STOKE_CONFIG_FILE: cfg });
assert.equal(localOnly.status, 0, localOnly.stderr);
assert.equal(localOnly.stdout, 'Local credentials removed.\n');
assert.equal(fs.existsSync(cfg), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('invalid --limit is rejected before any network call', () => {
const res = run(['repo', 'list', '-l', 'abc']);
assert.equal(res.status, 1);

View file

@ -1,6 +1,7 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
@ -11,6 +12,11 @@ const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf');
const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
const PACKAGE_MANIFEST = path.join(__dirname, '..', 'package.json');
const PACKAGE_LOCK = path.join(__dirname, '..', 'package-lock.json');
const CEREMONY_REPOSITORY = 'https://forgejo.heavyduty.builders/heavy-duty/ceremony';
const CEREMONY_VERSION = '0.6.3';
const CEREMONY_WORKFLOWS = ['labels.yml', 'labels-sweep.yml'];
const cleanups = [];
process.on('exit', () => {
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
@ -21,12 +27,30 @@ function writeConfig(contents) {
cleanups.push(dir);
const config = path.join(dir, 'labels.conf');
fs.writeFileSync(config, contents);
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
return config;
}
function runValidator(config, apiUrl) {
function writeRepository(configContents, labelerContents, files = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-repository-'));
cleanups.push(dir);
fs.mkdirSync(path.join(dir, '.github'), { recursive: true });
fs.writeFileSync(path.join(dir, '.github', 'labels.conf'), configContents);
fs.writeFileSync(path.join(dir, '.github', 'labeler.yml'), labelerContents);
for (const [filename, contents] of Object.entries(files)) {
const target = path.join(dir, filename);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
}
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '.'], { cwd: dir });
return { dir, config: path.join(dir, '.github', 'labels.conf') };
}
function runValidator(config, apiUrl, cwd = path.join(__dirname, '..')) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
cwd,
encoding: 'utf8',
});
let stdout = '';
@ -106,6 +130,71 @@ test('governance validator rejects malformed scope rows before identity requests
assert.doesNotMatch(result.stderr, /fetch failed/);
});
test('governance validator rejects a tracked path outside every scope and the residue allowlist', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
'scope:ci|C5DEF5|.github/ — governance surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
'"scope:ci":',
' - changed-files:',
' - any-glob-to-any-file: [".github/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, {
'src/covered.js': '',
'new-surface/uncovered.txt': '',
});
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /tracked paths have no scope mapping: new-surface\/uncovered\.txt/);
});
});
test('governance validator rejects scope names declared only in the labeler map', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
'"scope:extra":',
' - changed-files:',
' - any-glob-to-any-file: ["extra/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /scope names only in labeler\.yml: scope:extra/);
});
});
test('governance validator rejects scope names declared only in labels.conf', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
'scope:renamed|C5DEF5|renamed/ — renamed surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /scope names only in labels\.conf: scope:renamed/);
});
});
test('repository governance config resolves the current four-member panel and five scopes', async () => {
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
await withIdentityServer(logins, async (apiUrl) => {
@ -115,30 +204,66 @@ test('repository governance config resolves the current four-member panel and fi
});
});
test('repository scope mapping covers every configured scope with the ruled paths', () => {
test('repository scope mapping covers every tracked path except the ruled residue', () => {
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
const expected = {
'scope:cli': ['src/**'],
'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'],
'scope:manifests': ['manifests/**'],
'scope:ci': ['.forgejo/workflows/**'],
'scope:docs': ['README.md', 'docs/**'],
};
const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
.flatMap((match) => JSON.parse(match[1]));
const tracked = execFileSync('git', ['ls-files'], {
cwd: path.join(__dirname, '..'),
encoding: 'utf8',
}).trim().split('\n');
const allowed = new Set([
'.gitignore',
'assets/logo-mark.svg',
...tracked.filter((filename) => /^test\/[^/]+\.test\.js$/.test(filename)),
]);
const uncovered = tracked.filter((filename) => (
!allowed.has(filename) && !globs.some((glob) => path.matchesGlob(filename, glob))
));
for (const [label, globs] of Object.entries(expected)) {
assert.match(labeler, new RegExp(`^"${label}":`, 'm'), `${label} has no mapping`);
for (const glob of globs) assert.ok(labeler.includes(JSON.stringify(glob)), `${label} does not map ${glob}`);
}
assert.deepEqual(uncovered, []);
});
test('repository carries the complete 0.6.1 doctrine mirror and root router', () => {
test('package lock versions match the package manifest', () => {
const manifest = JSON.parse(fs.readFileSync(PACKAGE_MANIFEST, 'utf8'));
const lock = JSON.parse(fs.readFileSync(PACKAGE_LOCK, 'utf8'));
assert.equal(lock.version, manifest.version, 'package-lock.json version is stale');
assert.equal(lock.packages[''].version, manifest.version, 'package-lock.json root package version is stale');
});
test('repository carries the complete Forgejo 0.6.3 doctrine mirror and root router', () => {
const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
for (const filename of vendored) {
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`);
}
const mirrorReadme = fs.readFileSync(path.join(REPOSITORY_MIRROR, 'README.md'), 'utf8');
const sourceVersionRecord = `[heavy-duty/ceremony](${CEREMONY_REPOSITORY}) at ${CEREMONY_VERSION}`;
assert.equal(
mirrorReadme.split(sourceVersionRecord).length - 1,
2,
'mirror README does not identify the exact Forgejo ceremony source and version in both records',
);
assert.match(mirrorReadme, /labels doctrine is vendored manually/);
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/);
assert.match(fs.readFileSync(ROOT_AGENTS, 'utf8'), /read\s+`.ceremony\/AGENTS\.md` first/i);
const rootAgents = fs.readFileSync(ROOT_AGENTS, 'utf8');
assert.ok(
rootAgents.includes(`[heavy-duty/ceremony](${CEREMONY_REPOSITORY})`),
'root router does not identify the Forgejo ceremony repository',
);
assert.match(rootAgents, /read\s+`.ceremony\/AGENTS\.md` first/i);
});
test('repository workflow pins use the exact Forgejo ceremony version', () => {
for (const workflow of CEREMONY_WORKFLOWS) {
const contents = fs.readFileSync(path.join(__dirname, '..', '.forgejo', 'workflows', workflow), 'utf8');
const prefix = `uses: heavy-duty/ceremony/.github/workflows/${workflow}@`;
const pins = contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith(prefix));
assert.deepEqual(
pins,
[`${prefix}${CEREMONY_VERSION}`],
`${workflow} does not pin ceremony ${CEREMONY_VERSION}`,
);
}
});

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

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

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