Compare commits

...

47 commits

Author SHA1 Message Date
92f2eb3949 Merge pull request 'fix: keep import-batch running after token failures' (#69) from build/65-import-batch-token-failure into main
All checks were successful
ci / test (push) Successful in 7m17s
Reviewed-on: #69
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 08:02:45 +00:00
codex-bot-andresmgsl
37e6a2ad5a test: cover explicit import token handling
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m17s
2026-09-04 07:31:26 +00:00
codex-bot-andresmgsl
82494e94fd test: support node engine floor
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 5m12s
2026-09-04 07:30:26 +00:00
33d583892e Merge pull request 'fix: normalize Debian payload modes' (#68) from build/63-normalize-deb-modes into main
All checks were successful
ci / test (push) Successful in 1m31s
Reviewed-on: #68
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 07:17:56 +00:00
codex-bot-andresmgsl
4333ce63bf docs: note import-batch token handling
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m18s
2026-09-04 07:00:17 +00:00
codex-bot-andresmgsl
5f2f58a249 test: cover import-batch result boundaries
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 2m6s
2026-09-04 06:59:58 +00:00
codex-bot-andresmgsl
1bb4bd608c fix: isolate import-batch token failures
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 20s
2026-09-04 06:58:57 +00:00
codex-bot-andresmgsl
721ba343cc test: reproduce import-batch token abort
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 18s
2026-09-04 06:58:02 +00:00
codex-bot-andresmgsl
44bbeadff6 docs: record deterministic Debian modes
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 2m38s
2026-09-04 06:28:51 +00:00
codex-bot-andresmgsl
cef903b77e fix: normalize Debian payload modes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 3m1s
2026-09-04 06:28:29 +00:00
codex-bot-andresmgsl
9404c09cae test: expose umask-dependent Debian modes
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 5m21s
2026-09-04 06:27:42 +00:00
74e52b4210 Merge pull request 'fix: keep Debian publish token out of curl argv' (#67) from build/62-publish-deb-token into main
All checks were successful
ci / test (push) Successful in 7m18s
Reviewed-on: #67
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 06:16:38 +00:00
c4110e7f7e Merge pull request 'fix: report unauthenticated auth state honestly' (#66) from build/64-auth-state into main
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: #66
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 06:16:20 +00:00
codex-bot-andresmgsl
4d17b8b113 docs: record private Debian publish headers
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 2m10s
2026-09-04 02:56:37 +00:00
codex-bot-andresmgsl
dcb169e8ab fix: protect Debian publish credentials
All checks were successful
labels / labels (pull_request) Successful in 15s
ci / test (pull_request) Successful in 25s
2026-09-04 02:55:49 +00:00
codex-bot-andresmgsl
1dfa2c173d test: cover private deb publish credentials
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 1m8s
2026-09-04 02:54:40 +00:00
codex-bot-andresmgsl
1335232002 test: tolerate Node floor module warning
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 3m14s
2026-09-04 02:28:57 +00:00
codex-bot-andresmgsl
bf84b19a07 docs: record auth state fixes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m18s
2026-09-04 01:56:31 +00:00
codex-bot-andresmgsl
aedce42c56 fix: report unauthenticated auth state honestly
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 4m0s
2026-09-04 01:56:14 +00:00
codex-bot-andresmgsl
ed3f234b8e test: cover unauthenticated auth state
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 2m50s
2026-09-04 01:55:12 +00:00
2230ca2501 Merge pull request 'docs: correct release token scope contract' (#61) from build/60-release-token-scopes into main
All checks were successful
ci / test (push) Successful in 17s
Reviewed-on: #61
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-03 07:30:21 +00:00
bbde478157 Merge pull request 'fix: clarify publish-deb auth failure' (#59) from build/57-publish-deb-auth-message into main
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: #59
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-03 07:30:11 +00:00
codex-bot-andresmgsl
0edba09a8a docs: correct release token scopes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 22:54:28 +00:00
codex-bot-andresmgsl
3068809b66 fix: clarify publish-deb auth failure
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 22:39:32 +00:00
codex-bot-andresmgsl
56c8f00d8b test: cover publish-deb token guard
Some checks failed
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Failing after 16s
2026-09-02 22:38:38 +00:00
088e7e2d66 Merge pull request 'chore: release stoke 1.5.0' (#58) from build/56-release-1-5-0 into main
All checks were successful
ci / test (push) Successful in 17s
release / deb (push) Successful in 21s
Reviewed-on: #58
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-02 20:18:58 +00:00
codex-bot-andresmgsl
e5ead6a0b3 test: decouple missing changelog sentinel
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 18s
2026-09-02 20:03:12 +00:00
codex-bot-andresmgsl
57fc3a3ddf chore: prepare 1.5.0 release
Some checks failed
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Failing after 16s
2026-09-02 19:54:57 +00:00
d6a21c9d9e Merge pull request 'feat: publish releases through stoke CLI' (#55) from build/54-publish-release into main
All checks were successful
ci / test (push) Successful in 16s
Reviewed-on: #55
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-02 11:22:57 +00:00
codex-bot-andresmgsl
ee88d7d395 ci: delegate release publication to stoke
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 09:24:17 +00:00
codex-bot-andresmgsl
571e1b1f1f feat: publish release assets through stoke
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 09:23:21 +00:00
codex-bot-andresmgsl
e3f4703e13 feat: extract publishable changelog sections
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 17s
2026-09-02 09:21:09 +00:00
codex-bot-andresmgsl
1aa6dc26a1 test: specify changelog section extraction
Some checks failed
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Failing after 16s
2026-09-02 09:19:18 +00:00
967efa027c Merge pull request 'docs: add repository intake forms' (#52) from build/50-issue-templates into main
All checks were successful
ci / test (push) Successful in 15s
Reviewed-on: #52
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-01 19:14:51 +00:00
codex-bot-andresmgsl
fd22c16069 chore: trigger synchronize verification
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 16s
2026-09-01 15:38:58 +00:00
codex-bot-andresmgsl
da43f96796 docs: add repository intake forms
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-09-01 15:34:46 +00:00
bef059d7b7 Merge pull request 'fix: audit repository scope coverage' (#49) from build/48-scope-coverage into main
All checks were successful
ci / test (push) Successful in 15s
Reviewed-on: #49
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-09-01 14:16:52 +00:00
codex-bot-andresmgsl
d84062af54 test: enforce scope coverage and parity
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-09-01 13:42:26 +00:00
codex-bot-andresmgsl
112f946802 fix: cover repository scope surfaces
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 16s
2026-09-01 13:40:03 +00:00
081e05ca5a Merge pull request 'docs: document stoke contribution facts' (#47) from build/46-contributing into main
All checks were successful
ci / test (push) Successful in 15s
Reviewed-on: #47
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-09-01 13:31:30 +00:00
codex-bot-andresmgsl
3ea8eb9032 docs: add stoke contribution facts
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 16s
2026-09-01 12:49:21 +00:00
9586d2c631 Merge pull request 'fix: guard package lock version parity' (#45) from build/43-lockfile-version-guard into main
All checks were successful
ci / test (push) Successful in 27s
Reviewed-on: #45
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 22:08:18 +00:00
01b25cac43 Merge pull request 'docs: update renamed Kimi login in README' (#44) from build/33-readme-login into main
All checks were successful
ci / test (push) Successful in 17s
Reviewed-on: #44
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 20:15:22 +00:00
codex-bot-andresmgsl
125bc04ede docs: record lockfile parity guard
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 20:09:37 +00:00
codex-bot-andresmgsl
902ada1623 fix: enforce package lock version parity
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 15s
2026-08-31 20:06:46 +00:00
codex-bot-andresmgsl
6bffd8adb0 docs: update renamed kimi login
All checks were successful
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Successful in 17s
2026-08-31 19:48:00 +00:00
21b91e870e Merge pull request 'docs: pin governance records to Forgejo ceremony 0.6.3' (#42) from build/36-ceremony-pin-proof into main
All checks were successful
ci / test (push) Successful in 16s
Reviewed-on: #42
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-31 19:46:36 +00:00
32 changed files with 1302 additions and 92 deletions

View file

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

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

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

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

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

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

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

6
.github/labeler.yml vendored
View file

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

6
.github/labels.conf vendored
View file

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

View file

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

56
CONTRIBUTING.md Normal file
View file

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

View file

@ -126,8 +126,8 @@ Example stored config:
```json ```json
{ {
"url": "https://forgejo.heavyduty.builders", "url": "https://forgejo.heavyduty.builders",
"login": "kimi-reviewer-andresmgsl", "login": "kimi-bot-andresmgsl",
"username": "kimi-reviewer-andresmgsl", "username": "kimi-bot-andresmgsl",
"email": "andres+4@heavyduty.builders", "email": "andres+4@heavyduty.builders",
"token": "<sha1>", "token": "<sha1>",
"tokenId": 42 "tokenId": 42
@ -187,7 +187,7 @@ stoke auth login
Non-interactive example using environment variables: Non-interactive example using environment variables:
```bash ```bash
export STOKE_USERNAME='kimi-reviewer-andresmgsl' export STOKE_USERNAME='kimi-bot-andresmgsl'
export STOKE_PASSWORD='...' export STOKE_PASSWORD='...'
stoke auth login stoke auth login
``` ```
@ -196,7 +196,7 @@ Password file example (avoids shell history and special-character issues):
```bash ```bash
chmod 600 /run/secrets/stoke-password chmod 600 /run/secrets/stoke-password
stoke auth login -n kimi-reviewer-andresmgsl --password-file /run/secrets/stoke-password stoke auth login -n kimi-bot-andresmgsl --password-file /run/secrets/stoke-password
``` ```
Existing token example: Existing token example:
@ -472,7 +472,7 @@ Options:
Example used to move the heavy-duty repositories into the `heavy-duty` organization: Example used to move the heavy-duty repositories into the `heavy-duty` organization:
```bash ```bash
stoke repo transfer -o kimi-reviewer-andresmgsl -r box --to heavy-duty stoke repo transfer -o kimi-bot-andresmgsl -r box --to heavy-duty
``` ```
Calls `POST /api/v1/repos/{owner}/{repo}/transfer`. Calls `POST /api/v1/repos/{owner}/{repo}/transfer`.
@ -491,7 +491,7 @@ Options:
``` ```
```bash ```bash
stoke issue list -o kimi-reviewer-andresmgsl -r box -s all -l 0 stoke issue list -o kimi-bot-andresmgsl -r box -s all -l 0
``` ```
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates. Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
@ -570,7 +570,7 @@ Options:
``` ```
```bash ```bash
stoke pr list -o kimi-reviewer-andresmgsl -r box -s all -l 0 stoke pr list -o kimi-bot-andresmgsl -r box -s all -l 0
``` ```
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates. Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
@ -853,7 +853,7 @@ Options:
``` ```
```bash ```bash
stoke branch list -o kimi-reviewer-andresmgsl -r box stoke branch list -o kimi-bot-andresmgsl -r box
``` ```
Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates. Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
@ -873,8 +873,8 @@ Options:
Example: Example:
```bash ```bash
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u andres --permission admin stoke collaborator add -o kimi-bot-andresmgsl -r infra -u andres --permission admin
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u dan --permission admin stoke collaborator add -o kimi-bot-andresmgsl -r infra -u dan --permission admin
``` ```
Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`. Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`.
@ -1010,7 +1010,7 @@ Options:
``` ```
```bash ```bash
stoke org team member-remove --team-id 1 -u kimi-reviewer-andresmgsl stoke org team member-remove --team-id 1 -u kimi-bot-andresmgsl
``` ```
Calls `DELETE /api/v1/teams/{id}/members/{username}`. Calls `DELETE /api/v1/teams/{id}/members/{username}`.
@ -1132,7 +1132,7 @@ Release checklist:
## Verification: heavy-duty repository imports ## Verification: heavy-duty repository imports
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`. The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-bot-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
| Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases | | Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

View file

@ -1 +0,0 @@
- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23).

View file

@ -1 +0,0 @@
- Enforced the exact Forgejo ceremony source and version across governance records and workflow pins. (#36).

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

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

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

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

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

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

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

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

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

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

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

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

4
package-lock.json generated
View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

View file

@ -295,6 +295,8 @@ auth
} else { } else {
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`); console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
} }
} else if (!config.tokenId && !options.localOnly) {
console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`);
} }
clearConfig(); clearConfig();
@ -313,8 +315,12 @@ auth
try { try {
const config = loadConfig(); const config = loadConfig();
if (!config || !config.token) { if (!config || !config.token) {
console.log('Not authenticated.'); if (options.json) {
return; console.log('{"authenticated": false}');
} else {
console.log('Not authenticated.');
}
process.exit(1);
} }
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
@ -649,30 +655,30 @@ repo
continue; 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 { 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); const result = await client.migrateRepo(payload);
console.log(`Imported: ${result.full_name} -> ${result.html_url}`); console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
results.push({ name, status: 'ok', url: result.html_url }); results.push({ name, status: 'ok', url: result.html_url });

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

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs'); const fs = require('node:fs');
const http = require('node:http'); const http = require('node:http');
const os = require('node:os'); const os = require('node:os');
@ -11,6 +12,8 @@ const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf');
const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml'); const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony'); const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md'); const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
const PACKAGE_MANIFEST = path.join(__dirname, '..', 'package.json');
const PACKAGE_LOCK = path.join(__dirname, '..', 'package-lock.json');
const CEREMONY_REPOSITORY = 'https://forgejo.heavyduty.builders/heavy-duty/ceremony'; const CEREMONY_REPOSITORY = 'https://forgejo.heavyduty.builders/heavy-duty/ceremony';
const CEREMONY_VERSION = '0.6.3'; const CEREMONY_VERSION = '0.6.3';
const CEREMONY_WORKFLOWS = ['labels.yml', 'labels-sweep.yml']; const CEREMONY_WORKFLOWS = ['labels.yml', 'labels-sweep.yml'];
@ -24,12 +27,30 @@ function writeConfig(contents) {
cleanups.push(dir); cleanups.push(dir);
const config = path.join(dir, 'labels.conf'); const config = path.join(dir, 'labels.conf');
fs.writeFileSync(config, contents); fs.writeFileSync(config, contents);
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
return config; return config;
} }
function runValidator(config, apiUrl) { function writeRepository(configContents, labelerContents, files = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-repository-'));
cleanups.push(dir);
fs.mkdirSync(path.join(dir, '.github'), { recursive: true });
fs.writeFileSync(path.join(dir, '.github', 'labels.conf'), configContents);
fs.writeFileSync(path.join(dir, '.github', 'labeler.yml'), labelerContents);
for (const [filename, contents] of Object.entries(files)) {
const target = path.join(dir, filename);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
}
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '.'], { cwd: dir });
return { dir, config: path.join(dir, '.github', 'labels.conf') };
}
function runValidator(config, apiUrl, cwd = path.join(__dirname, '..')) {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], { const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
cwd,
encoding: 'utf8', encoding: 'utf8',
}); });
let stdout = ''; let stdout = '';
@ -109,6 +130,71 @@ test('governance validator rejects malformed scope rows before identity requests
assert.doesNotMatch(result.stderr, /fetch failed/); assert.doesNotMatch(result.stderr, /fetch failed/);
}); });
test('governance validator rejects a tracked path outside every scope and the residue allowlist', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
'scope:ci|C5DEF5|.github/ — governance surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
'"scope:ci":',
' - changed-files:',
' - any-glob-to-any-file: [".github/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, {
'src/covered.js': '',
'new-surface/uncovered.txt': '',
});
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /tracked paths have no scope mapping: new-surface\/uncovered\.txt/);
});
});
test('governance validator rejects scope names declared only in the labeler map', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
'"scope:extra":',
' - changed-files:',
' - any-glob-to-any-file: ["extra/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /scope names only in labeler\.yml: scope:extra/);
});
});
test('governance validator rejects scope names declared only in labels.conf', async () => {
const config = [
'panel=codex-bot-andresmgsl',
'scope:cli|C5DEF5|src/ — command surface',
'scope:renamed|C5DEF5|renamed/ — renamed surface',
].join('\n');
const labeler = [
'"scope:cli":',
' - changed-files:',
' - any-glob-to-any-file: ["src/**"]',
].join('\n');
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
const result = await runValidator(repository.config, apiUrl, repository.dir);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /scope names only in labels\.conf: scope:renamed/);
});
});
test('repository governance config resolves the current four-member panel and five scopes', async () => { test('repository governance config resolves the current four-member panel and five scopes', async () => {
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']); const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
await withIdentityServer(logins, async (apiUrl) => { await withIdentityServer(logins, async (apiUrl) => {
@ -118,20 +204,32 @@ test('repository governance config resolves the current four-member panel and fi
}); });
}); });
test('repository scope mapping covers every configured scope with the ruled paths', () => { test('repository scope mapping covers every tracked path except the ruled residue', () => {
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8'); const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
const expected = { const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
'scope:cli': ['src/**'], .flatMap((match) => JSON.parse(match[1]));
'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'], const tracked = execFileSync('git', ['ls-files'], {
'scope:manifests': ['manifests/**'], cwd: path.join(__dirname, '..'),
'scope:ci': ['.forgejo/workflows/**'], encoding: 'utf8',
'scope:docs': ['README.md', 'docs/**'], }).trim().split('\n');
}; const allowed = new Set([
'.gitignore',
'assets/logo-mark.svg',
...tracked.filter((filename) => /^test\/[^/]+\.test\.js$/.test(filename)),
]);
const uncovered = tracked.filter((filename) => (
!allowed.has(filename) && !globs.some((glob) => path.matchesGlob(filename, glob))
));
for (const [label, globs] of Object.entries(expected)) { assert.deepEqual(uncovered, []);
assert.match(labeler, new RegExp(`^"${label}":`, 'm'), `${label} has no mapping`); });
for (const glob of globs) assert.ok(labeler.includes(JSON.stringify(glob)), `${label} does not map ${glob}`);
} test('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', () => { test('repository carries the complete Forgejo 0.6.3 doctrine mirror and root router', () => {

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), []);
});