forked from heavy-duty/stoke
Compare commits
112 commits
design/fig
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92f2eb3949 | |||
|
|
37e6a2ad5a | ||
|
|
82494e94fd | ||
| 33d583892e | |||
|
|
4333ce63bf | ||
|
|
5f2f58a249 | ||
|
|
1bb4bd608c | ||
|
|
721ba343cc | ||
|
|
44bbeadff6 | ||
|
|
cef903b77e | ||
|
|
9404c09cae | ||
| 74e52b4210 | |||
| c4110e7f7e | |||
|
|
4d17b8b113 | ||
|
|
dcb169e8ab | ||
|
|
1dfa2c173d | ||
|
|
1335232002 | ||
|
|
bf84b19a07 | ||
|
|
aedce42c56 | ||
|
|
ed3f234b8e | ||
| 2230ca2501 | |||
| bbde478157 | |||
|
|
0edba09a8a | ||
|
|
3068809b66 | ||
|
|
56c8f00d8b | ||
| 088e7e2d66 | |||
|
|
e5ead6a0b3 | ||
|
|
57fc3a3ddf | ||
| d6a21c9d9e | |||
|
|
ee88d7d395 | ||
|
|
571e1b1f1f | ||
|
|
e3f4703e13 | ||
|
|
1aa6dc26a1 | ||
| 967efa027c | |||
|
|
fd22c16069 | ||
|
|
da43f96796 | ||
| bef059d7b7 | |||
|
|
d84062af54 | ||
|
|
112f946802 | ||
| 081e05ca5a | |||
|
|
3ea8eb9032 | ||
| 9586d2c631 | |||
| 01b25cac43 | |||
|
|
125bc04ede | ||
|
|
902ada1623 | ||
|
|
6bffd8adb0 | ||
| 21b91e870e | |||
|
|
c900f47d77 | ||
|
|
21fcb1fdb1 | ||
|
|
ed16f824ef | ||
|
|
c5e0d25c49 | ||
| c34a8b04d2 | |||
|
|
3fac8096f7 | ||
|
|
52e8d45b78 | ||
|
|
316bec5855 | ||
|
|
04e6ba60e8 | ||
|
|
b21a1387a5 | ||
| 523a45587f | |||
|
|
ea6c1a4fe9 | ||
|
|
64b3d9df94 | ||
|
|
3f943cf921 | ||
| fb5cb4746b | |||
|
|
2efc76f23e | ||
|
|
9cc9576116 | ||
|
|
a89eafaebc | ||
|
|
9f34641545 | ||
| 25c7267ecd | |||
| 5ec01f56b7 | |||
| f9a8ad46f4 | |||
| 6cd2bb583a | |||
| 1c6d8cce73 | |||
| cca75fef3e | |||
| 125e44afe9 | |||
| 92ba146b44 | |||
| 4a62f7e0cf | |||
| 033a40c7a0 | |||
|
|
769a3c8aba | ||
|
|
c7971eefe0 | ||
|
|
a28b2ffd74 | ||
|
|
acb46d0707 | ||
|
|
3c0709189e | ||
|
|
8293c83531 | ||
|
|
1371ec9aa9 | ||
|
|
d1c80db665 | ||
|
|
0fac095509 | ||
| c09943ea32 | |||
|
|
ccaeb8ee4e | ||
|
|
914e4c444b | ||
| 95f9eb8060 | |||
|
|
db36cf2efc | ||
|
|
47aed6f1de | ||
|
|
9efe4bf717 | ||
|
|
a935b84ef8 | ||
|
|
e86ce95180 | ||
| 4c6185898e | |||
|
|
a62a753656 | ||
|
|
7b372eb2dc | ||
| ee0cb85c7b | |||
|
|
0531bde366 | ||
| 87b3cf98d9 | |||
| 907917a870 | |||
| c85be2e083 | |||
|
|
955ce393fc | ||
|
|
8255c568b1 | ||
|
|
b4b38d1d97 | ||
| 1165ee22c3 | |||
| f5a44021da | |||
| 3e93b20ae6 | |||
| f4b0bdbe4e | |||
| 036364f844 | |||
| 355fcc1f67 | |||
| 1b990d523a |
55 changed files with 5942 additions and 122 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# AGENTS.md — start here
|
||||
|
||||
You are an agent working in a repo governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
|
||||
the router: find your role below, read its file, then act. The role files
|
||||
sit beside this one — in ceremony itself at the repo root, in a governed
|
||||
repo under `.ceremony/` (a machine-managed mirror; never edit those files
|
||||
in place — they are changed in heavy-duty/ceremony, through its own flow).
|
||||
|
||||
## Your role
|
||||
|
||||
You were told your role when you were pointed at this repo ("you are a
|
||||
reviewer here"). That one word is your whole onboarding:
|
||||
|
||||
| you are the… | read | your job in one line |
|
||||
|---|---|---|
|
||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
|
||||
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||
|
||||
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
|
||||
are the shared state machine, and misusing one lies to every other agent on
|
||||
the board.
|
||||
|
||||
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||
asked to implement an issue → builder; asked to process proposals or the
|
||||
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||
pushing fixes, breaks the separation the pipeline depends on.
|
||||
|
||||
## The pipeline you are part of
|
||||
|
||||
```
|
||||
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
Two rules bind every role:
|
||||
|
||||
- **Only triage mints work issues.** Found work? File or extend a proposal.
|
||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||
a merge button.
|
||||
|
||||
## Repo specifics
|
||||
|
||||
What is true only of *this* repo — the review panel roster, the `scope:*`
|
||||
label set, what a drill means, code conventions — lives in the repo's own
|
||||
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
|
||||
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.
|
||||
309
.ceremony/BUILDER.md
Normal file
309
.ceremony/BUILDER.md
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
# BUILDER.md — the builder role
|
||||
|
||||
You turn one issue into one PR. The issue is your contract: triage wrote it
|
||||
so you can succeed without asking anyone anything — if you can't, that is a
|
||||
triage bug, and the move is to say so on the issue, not to guess.
|
||||
|
||||
## Picking
|
||||
|
||||
- Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an
|
||||
`epic` (epics organize; their children are the work). Inside an epic take
|
||||
the earliest unblocked unclaimed child, otherwise the issue that unblocks
|
||||
the most work; where a repo adopts version epics,
|
||||
[RELEASES.md](RELEASES.md) governs among window members.
|
||||
- **Your own red head outranks a new claim**: repair a failing check at your
|
||||
PR's head before claiming another issue (#163). Red and green here are the
|
||||
review round's ruled terms: cancelled, stale, or unreported — every entry
|
||||
at the head cancelled — is not green; skipped or neutral is. Record the
|
||||
check and its failure class; rerun a clearly retryable infrastructure
|
||||
failure unchanged; treat a branch failure as an ordinary fix round,
|
||||
worklog and all; leave evidence where a rerun cannot start or the cause is
|
||||
unclear; never rerun a deterministic failure without a corrective commit;
|
||||
hand off once green with current-head approvals. Such a PR is **never
|
||||
parked**, whatever the verdict state says; how the engine detects a red
|
||||
head is crew's to describe.
|
||||
- **One build at a time**: one issue on which you are writing or revising a
|
||||
deliverable, finished or released before you start more. The rule counts
|
||||
work in flight, not claims — a **parked** claim, whose next move is
|
||||
someone else's, does not hold the slot. Five shapes park:
|
||||
1. `needs-ruling` is set, the escalation names a decider, and its
|
||||
`Blocked:` line stops the rest;
|
||||
2. a **live** review round holds it, every outstanding verdict someone
|
||||
else's — awaiting first verdicts, or answered whole with the owed
|
||||
re-requests posted, by head and not by verdict (steps 1–2). A red check
|
||||
at the head takes it out of this shape: the next move is yours;
|
||||
3. every remaining acceptance criterion is operator-owned, stated so by
|
||||
triage on the issue. **An operator-owned remainder parks the claim and
|
||||
never the handoff**: this shape is reached only from the far side of
|
||||
shape 4, because it is the state finishing the work puts you in and
|
||||
would otherwise excuse the handoff it should follow (#336);
|
||||
4. it is **handed off** — round passed, no `blocker:*` standing,
|
||||
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
|
||||
and 4 are sequential and never overlap;
|
||||
5. the claim is **held by directive** — triage or the operator stopped the
|
||||
work, named what the hold waits on, and only they end it. A hold ends
|
||||
as it started, **on the labels**: where labels and prose disagree, the
|
||||
most recent queue-label event by the hold's owner governs, and an
|
||||
operator may lift by label alone (#149, #151). So read the label events
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the
|
||||
comments, before standing down *or* up, and say in the claim which you
|
||||
read, their timestamps and their actor. Where they do not resolve the
|
||||
contradiction, say so and take the next `ready` issue; refusing is no
|
||||
resting place.
|
||||
Not parked: waiting on yourself, on CI (a red head is yours; a pending one
|
||||
resolves without you), or for a good moment. An issue you stopped working
|
||||
on is abandoned — unassign and restore `ready`. Parked claims are held
|
||||
beside the one active build (#15, #16, #73).
|
||||
|
||||
## Claiming
|
||||
|
||||
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||
starting. The claim promises a draft PR soon: a claim with no PR and no
|
||||
activity is what the staleness sweep reclaims, unless `offsite` records
|
||||
that its PR lives in another repo.
|
||||
- **A park is declared, never inferred.** Comment naming what the claim
|
||||
waits on and who owns the next move — no new label; the comment is the
|
||||
activity the reclaim clock reads, as for `needs-ruling` (#52) and
|
||||
`offsite` (#68). Shape 4 is exempt: the handoff comment and
|
||||
`state:needs-human` already say both.
|
||||
- **A declaration stands until the park's facts change**, so a resumption
|
||||
finding nothing changed posts nothing (#177). Each change owes one comment
|
||||
— the wait resolves or changes hands, the shape changes, the claim
|
||||
unparks. A parked claim with **no open PR** still feeds the 48-hour
|
||||
reclaim clock, so refresh the declaration before it closes; that is a
|
||||
park's only repeat.
|
||||
- **Pick up `attention` before anything else**: post a short pickup comment
|
||||
and remove the label, which is the ack. A demand on a parked claim is
|
||||
usually its unpark, so take the slot back — unless the demand *is* the
|
||||
park, the pickup comment then doubling as the declaration.
|
||||
- **A directed hold keeps its bookkeeping visible.** The PR carries
|
||||
`blocked` with a comment naming what it waits on; the issue stays
|
||||
`claimed` and carries `attention` until the builder acks. Nobody unassigns
|
||||
it, and the 48-hour reclaim does not fire while the claim has an open PR.
|
||||
- **Unparking is a claim like any other** and takes the slot: if you are
|
||||
active elsewhere, finish or release that work first and say which on both
|
||||
issues. No machinery counts claims per builder, and none should be built
|
||||
expecting this section to have specified one.
|
||||
- **Abandoning is fine; ghosting is not.** Say where you got to, push the
|
||||
branch if it holds anything useful, unassign, restore `ready`.
|
||||
|
||||
## Building
|
||||
|
||||
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||
body. Drafts are invisible to the panel on purpose: that phase is yours.
|
||||
- **`Closes #N` does not cross repos.** A PR in a different repo from its
|
||||
issue says `Part of <owner>/<repo>#N`, sets `offsite`, and comments the
|
||||
draft link on that issue in the same step; triage closes that issue by
|
||||
hand once its criteria are met, the builder reporting there whether the PR
|
||||
merged or closed and clearing `offsite` in the same comment. The
|
||||
cross-repo merge never closes the authorizing issue (#13, #16).
|
||||
- **`Closes #N` does not survive a post-merge criterion.** Where the issue
|
||||
body says a criterion can only be checked after the merge — a workflow
|
||||
trigger proved live, a released artifact, anything whose subject does not
|
||||
exist until the change is on the base branch — the same-repo PR says
|
||||
`Refs #N`; the issue goes `post-merge` at the merge, the builder walks
|
||||
away, and triage owns verification and closure on the evidence, returning
|
||||
the issue to `ready` or minting a fresh one where corrective work is
|
||||
needed — claimable by any builder from current `main`, the original having
|
||||
no special standing. The issue body says so — you never judge which
|
||||
qualify — and absent it `Closes #N` is the default (#151).
|
||||
- On a `Refs #N` PR, never put a closing keyword (`close`, `closes`,
|
||||
`closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`)
|
||||
immediately before `#N` anywhere in the body, including the sentence
|
||||
explaining why the PR does not close it: GitHub reads the body by
|
||||
adjacency, not intent, and a code span does not protect the phrase (#200,
|
||||
#218). Put the number first (`#N is closed by hand`) or omit it.
|
||||
- **The issue's acceptance criteria are your definition of done**: reproduce
|
||||
them as a checklist in the PR body and check them honestly. One that turns
|
||||
out wrong or unreachable goes back to triage to be amended, never silently
|
||||
shipped short.
|
||||
- **Every behavior change writes one fragment**, `changelog.d/<issue>.md`
|
||||
named for the authorizing issue (`<repo>-<issue>.md` cross-repo): the
|
||||
prose to be published and nothing else — `- ` bullets, plus in a grouped
|
||||
repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only
|
||||
where a change genuinely is one. An entry is at most 300 characters, so a
|
||||
long change ships several short ones (wrapping over continuation lines is
|
||||
free), and it **ends with its issue citation**: a parenthesised group of
|
||||
`#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and
|
||||
nothing after — `(#262).`, `(#236, #250).` — which need not name the
|
||||
fragment's own issue, the filename carrying it. The guard reds a long
|
||||
entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the
|
||||
release PR assembles the section from fragments (#112), and the monotonic
|
||||
guard refuses anything deleting a shipped heading.
|
||||
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||
not optional: the issue's test plan is the floor, not the ceiling.
|
||||
- **A write-capable job gets a repo-owned script, not a third-party
|
||||
action.** Where the token can write (`packages: write`, `contents: write`,
|
||||
`id-token: write`, deploy secrets), default to a script a test can drive;
|
||||
a third-party action there needs an established publisher and a
|
||||
full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and
|
||||
its red-flag profile are in REVIEWER.md §What you review against, item 2
|
||||
(#216).
|
||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||
Adjacent problems go to a proposal, or a comment on the relevant issue;
|
||||
you do not mint work issues — nobody but triage does — and you do not fix
|
||||
drive-by findings in the same PR.
|
||||
|
||||
## The review round
|
||||
|
||||
(In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts
|
||||
such as the panel roster live in that repo's own CONTRIBUTING.)
|
||||
|
||||
1. Mark ready-for-review; request **the whole panel**: the PR repo's
|
||||
`panel[<your-login>]=` line if it defines one, else its `panel=` line,
|
||||
minus the author (#224) — never the roster of the repo the issue is in.
|
||||
That repo's `.github/labels.conf` governs over its CONTRIBUTING roster,
|
||||
being what the state machine reads; where it names no roster, ask triage
|
||||
on the authorizing issue rather than guess. An off-panel reviewer may be
|
||||
requested, said to be advisory and not required.
|
||||
|
||||
**A review request requires a green check at the head**, whether or not
|
||||
an engine enforces it: a red check is the author's own signal, so fix it
|
||||
and push, then request. The one exception is a failure genuinely outside
|
||||
the PR — a runner outage, a flaky dependency, a failure already on the
|
||||
default branch — and only where the request says so and names the
|
||||
evidence ("the same job fails identically on `origin/main` at `<sha>`");
|
||||
silence about a red check is what is prohibited, and an argued exception
|
||||
shifts the burden to the author.
|
||||
|
||||
*Green* is a ruled term (operator, 2026-07-27), read in two steps.
|
||||
**First take the check's word at this head**: its newest entry by start
|
||||
time — not completion, a cancelled run outliving its replacement's start
|
||||
— and never a `CANCELLED` entry while the same check has a non-cancelled
|
||||
one there. A check whose entries at the head are all cancelled has not
|
||||
reported at all and is not green — a collapse, not a new class, and the
|
||||
gate partitions alike, dropping a cancelled entry only where a
|
||||
non-cancelled survivor remains and leaving an all-cancelled context
|
||||
blocking (#139, #276). **Then classify that entry by `conclusion`, never
|
||||
`status`**, which can disagree with it (#259). No conclusion is not
|
||||
green: a configured run in progress is waited on, and waiting is
|
||||
compliance, not a stall — the wait is the **request's**, and never a
|
||||
reason to withhold the declaration that a round was answered (step 2).
|
||||
Cancelled or stale is not green, *stale* being a
|
||||
superseded head's check, which a head-scoped rollup never shows. Skipped
|
||||
or neutral is green, those being deliberate "passed / not applicable"
|
||||
conclusions. No checks configured is green — the third ruled case, not an
|
||||
argued exception, so the request goes out at once with no evidence owed;
|
||||
that never covers nothing-answered-yet, and the machine partitions alike,
|
||||
admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the
|
||||
line are asymmetric: a false green spends a three-reviewer round, a false
|
||||
red one author session. What the machine drops from the rollup before
|
||||
grading is crew's to describe.
|
||||
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||
covering every point, stating what changed and what was verified. That
|
||||
reply is the written record: the engine mirrors it under the PR body's
|
||||
**Round log**, newest last and marked with the round's head, which makes
|
||||
a retry a no-op; you owe the reply and no body edit, and a round answered
|
||||
without one is recorded as such and never blocks handoff. Then push the
|
||||
fixes and re-request **by head, not by verdict**. A push makes every
|
||||
approval stale — an approval is of a specific tree, and the handoff
|
||||
predicate counts only approvals at the current head — so **every panelist
|
||||
is re-requested, approvers included**; one left un-re-requested can never
|
||||
approve the tree you shipped (#26, #39). Only where the head did not move
|
||||
— answered with argument or evidence, nothing pushed — do you re-request
|
||||
just the non-approvers; the engine absorbs a re-request at an unchanged
|
||||
head, and its mechanism is crew's to describe (#94). **The re-request
|
||||
carries the same green-check-at-head precondition**, argued exception
|
||||
included: a fix push whose check comes up red is your next fix, not the
|
||||
panel's. **Where an engine mediates the request, that precondition binds
|
||||
the engine's act and not yours**: declaring a round answered is not
|
||||
requesting the panel, so declare it as soon as the round's fixes are
|
||||
pushed and stop. The engine holds the request while the head is pending
|
||||
or red, so an early declaration cannot produce an early request while a
|
||||
withheld one is indistinguishable from a session that died (#330).
|
||||
**Never wait on an event you have no wake for** — where the engine is
|
||||
what observes the check settling, the wait is the engine's to keep
|
||||
(#330). **Never block on a producer you cannot prove alive either**:
|
||||
where a job signals its own completion, that signal is the wake and the
|
||||
finished output is read afterwards, because a follow on a file nothing is
|
||||
writing cannot tell *not yet* from *never* (#336). Prefer verification
|
||||
over argument — add the test that settles the doubt.
|
||||
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||
A blocking point you disagree with is answered with evidence or escalated
|
||||
in the PR; silence and force-forward are not options, and a panel
|
||||
deadlock is one kind of human-owned decision (#50 D11).
|
||||
|
||||
**A fix round may ride a draft**, and the draft changes nothing about who
|
||||
owes what: a mid-round draft reads as a draft always read — the phase is
|
||||
yours, the panel cannot see it — while the round outranks it, so you owe the
|
||||
round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s
|
||||
`state:building` row, #205). **Ready-for-review is the act that ends the
|
||||
round, and it is the builder's alone**: the flip asserts the round was
|
||||
answered whole, the one judgement its author cannot delegate, so an engine
|
||||
may draft a PR but only the builder undrafts it. **Where a draft suppressed
|
||||
the checks, green is proven at the flip and the request still follows it** —
|
||||
marking ready runs the checks the draft held back, so the order is flip, let
|
||||
the head answer, then request, step 1's precondition and not a second one.
|
||||
Waiting there is compliance — again the request's wait, not the
|
||||
declaration's — and `blocker:unrequested` does not fire while a head's
|
||||
checks are pending or red (#236).
|
||||
|
||||
## The ruling ask
|
||||
|
||||
Set `needs-ruling` whenever a decision belongs to a human: org policy,
|
||||
published artifacts, secrets, prod, or any choice whose cost lands outside
|
||||
the PR — a panel deadlock is one instance, not the definition. The builder
|
||||
is the PR's accountable flag-setter and consolidates the decision into one
|
||||
comment rather than forwarding several reviewers' phrasings (#50 D11).
|
||||
|
||||
Keep at most these five lines above the fold, all other analysis inside it.
|
||||
The field labels are fixed because the ruling machinery checks for them (#50
|
||||
D12):
|
||||
|
||||
```text
|
||||
🧭 needs-ruling — <the decision, one line>
|
||||
Options: A — <one clause> B — <one clause>
|
||||
Recommend: A, because <one clause>.
|
||||
Blocked: <what stops; what continues meanwhile>
|
||||
Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
|
||||
<details><summary>Analysis</summary>…everything else…</details>
|
||||
```
|
||||
|
||||
The options must be exhaustive and mutually exclusive; more than three means
|
||||
the question is not ready. `Recommend:` is mandatory — omitting it hands the
|
||||
whole problem to the human. `Blocked:` names both what stops and what
|
||||
continues. Write a timed `Default:` only when affirmatively confident the
|
||||
decision is reversible inside the PR before merge; unsure is not a tie but a
|
||||
hard block, as published artifacts, secrets, prod and org policy are by
|
||||
construction (#50 D12–D13).
|
||||
|
||||
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
|
||||
event**, not its `Default:` deadline or the last activity (#50 D13–D14):
|
||||
|
||||
- **0–12h:** proceed when a still-clear, reversible default expires, saying
|
||||
out loud that you did; a hard block waits.
|
||||
- **at 12h:** do not fire a stale default — re-read it against what has
|
||||
landed, and where doubt has appeared, make it a hard block.
|
||||
- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the
|
||||
body which way you went and what doubt remains. Nothing merges by this;
|
||||
the human still gates the merge.
|
||||
- **past 24h:** hand the choice to triage, which picks the option, records
|
||||
it as a decision, and stays accountable; the operator can overturn it at
|
||||
merge.
|
||||
|
||||
A re-flag starts a fresh ladder, which applies whatever `Default:` says,
|
||||
hard block included, and an active back-and-forth still climbs it — unlike
|
||||
the 7-day nudge, which resets on real activity. The machine observes both
|
||||
clocks but never sets, clears, or decides `needs-ruling`. The label stays
|
||||
until agreement is *reached*, not until the maintainer replies: the setter
|
||||
records the ruling, removes the label, and returns the item to its flow in
|
||||
the same comment ([LABELS.md](LABELS.md)).
|
||||
|
||||
## Handoff
|
||||
|
||||
When the round passes — every panel verdict approving the **current head**,
|
||||
no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this
|
||||
is a release PR) — the engine does these steps for the builder, in order:
|
||||
|
||||
1. request the human's review;
|
||||
2. set `state:needs-human`;
|
||||
3. post the engine-rendered handoff comment: approvals at the current head,
|
||||
the head SHA, and a pointer to the PR body's **Round log**.
|
||||
|
||||
The builder composes no new summary: the authored record already lives in
|
||||
the Round log, mirrored from each whole-round reply. The label write is
|
||||
optimistic — the reconciler validates it and takes it back if the PR is not
|
||||
mergeable-right-now. Then stop: the PR is the human's, and the claim parks
|
||||
as shape 4 (Picking, above), that comment its declaration and your slot
|
||||
free. Address what comes back (`state:addressing`) and re-hand-off the same
|
||||
way.
|
||||
245
.ceremony/LABELS.md
Normal file
245
.ceremony/LABELS.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Labels
|
||||
|
||||
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
|
||||
differs per repo (each repo's `.github/labels.conf` names its actual
|
||||
surfaces); everything else below is core and identical everywhere, created by
|
||||
the labels workflow's dispatch (which is also the operator's manual
|
||||
full-board reconcile sweep; issue #10).
|
||||
|
||||
Two state machines share the taxonomy: the **PR machine** (proven in
|
||||
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
||||
triage → build queue, reconciled by the work-queue sweep). One rule joins
|
||||
everything: **states are machine-owned, intent
|
||||
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
|
||||
and the reconciler recomputes it from GitHub's own facts.
|
||||
|
||||
## PR state — who is the ball with? (exactly one per open PR)
|
||||
|
||||
| Label | Color | Waiting on |
|
||||
|---|---|---|
|
||||
| `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) |
|
||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
|
||||
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||
|
||||
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
|
||||
*poke the reviewers*, in the second *the builder dropped the ball*. And
|
||||
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||
so it requires zero blockers and head-current approvals; anything less and
|
||||
the reconciler takes it back. The author sets it at handoff (the one
|
||||
hand-set state). On a same-repository head, the `labeled` event fires the
|
||||
sweep that validates the write within seconds; on a fork head whose
|
||||
`pull_request_target` token is read-only, validation waits for the scheduled
|
||||
sweep cadence (#241).
|
||||
|
||||
## PR blockers — what is in the way? (facts, as many as apply)
|
||||
|
||||
| Label | Color | Means |
|
||||
|---|---|---|
|
||||
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
|
||||
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
|
||||
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
|
||||
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
|
||||
|
||||
States answer *whose ball*; blockers answer *what's in the way*. They are
|
||||
separate axes because the single-label version kept lying — independent facts
|
||||
projected onto one totally-ordered label meant one always won and the losers
|
||||
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
|
||||
strips it on sight).
|
||||
|
||||
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
|
||||
|
||||
| Label | Color | Means | Set by |
|
||||
|---|---|---|---|
|
||||
| `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
|
||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||
| `post-merge` | `#006B75` | the Refs-linked PR merged; post-merge acceptance criteria remain; the claim is released — nothing here is buildable and nobody owes a draft | the sweep or triage |
|
||||
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||
|
||||
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
|
||||
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||
`blocked` / `post-merge`. It flags conflicts rather than guessing intent. A `claimed` issue
|
||||
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
|
||||
comments, unassigns the stale owner, and restores `ready`.
|
||||
|
||||
When a merged PR references a `claimed` issue with `Refs #N` and unchecked
|
||||
criteria remain, the sweep moves the issue to `post-merge`, clears the
|
||||
assignee, and comments with the remaining criteria verbatim. The comment says
|
||||
that the claim is released and that triage owes a follow-up naming the owner
|
||||
and wake condition for completion. Triage writes that full transition comment
|
||||
in the same tick when it or the operator makes the move by hand. The sweep
|
||||
never reclaims `post-merge`: weeks of quiet can be the state working. It does
|
||||
make the quiet visible — after 7 days with no comment on the issue, the sweep
|
||||
posts one nudge naming the triage actor, saying the wake evidence is owed and
|
||||
linking the item. Only a comment resets that clock: label churn does not, and
|
||||
neither does an assignment, which is the claim clock's fact and on this queue
|
||||
state is the invalid composition flagged below. Which criterion starved is
|
||||
prose the machine never judges; the link is the payload. Like the ruling nudge
|
||||
it carries no idempotency marker on purpose — the comment is itself activity,
|
||||
so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no
|
||||
label.
|
||||
|
||||
`post-merge` never composes with `blocked`; the transition comment carries the
|
||||
wait. It never composes with `attention`, because releasing the claim clears
|
||||
the assignee and leaves nobody parked-for. An assigned `post-merge` issue is
|
||||
flagged rather than repaired: a hand-assignment is intent. `needs-ruling`
|
||||
still composes. When the remainder becomes buildable, triage moves
|
||||
`post-merge` to `ready` or mints a fresh `ready` issue. Any builder may claim
|
||||
that work from current `main`; the original builder has no special standing,
|
||||
and re-entry does not set `attention`.
|
||||
|
||||
## Cross-cutting (PRs and issues)
|
||||
|
||||
| Label | Color | Meaning |
|
||||
|---|---|---|
|
||||
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
||||
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it). The reconciler refuses `state:needs-human` while `blocked` stands — the PR falls to `state:addressing` (#180) |
|
||||
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
|
||||
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
|
||||
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
|
||||
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
|
||||
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
|
||||
|
||||
`needs-ruling` marks where the human's turn is when the pending thing is a
|
||||
*decision*, not a merge ([#50 D1–D14](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
It applies to any human-owned decision — org policy, published artifacts,
|
||||
secrets, prod, or any choice whose cost lands outside the work. A panel
|
||||
deadlock is one instance, not the definition (D11). It is not
|
||||
`state:needs-human`: that label means exactly "this PR could be merged right
|
||||
now", and the retired `state:needs-rebase` is the family's proof that a
|
||||
label meaning two things lies about both. It is not a `blocker:*` either:
|
||||
every blocker names work the *builder* owes, a ruling is owed by the human —
|
||||
and the flag must live on issues too, where blockers do not exist. On issues
|
||||
it coexists with the queue labels (the one-of-three invariant above ignores
|
||||
it); its color is the light shade of `state:needs-human`'s, so the human
|
||||
axis reads as one family. It is a state, not a signal: set only with the
|
||||
[canonical escalation contract](BUILDER.md#the-ruling-ask) (D12). A bare
|
||||
flag is noise. The comment carries exhaustive, mutually exclusive options
|
||||
(at most three), a mandatory recommendation, what stops and what continues,
|
||||
and either a default affirmatively known to be reversible inside the PR or
|
||||
`none — hard block`. Unsure is a block; published artifacts, secrets, prod,
|
||||
and org policy are hard blocks by construction (D13).
|
||||
|
||||
The ruling ladder runs from the current episode's `needs-ruling` **`labeled`
|
||||
event** (D13–D14):
|
||||
|
||||
- **0–12h:** a clear, reversible decision may proceed when its stated default
|
||||
expires, saying out loud that it did; anything with reasonable doubt waits
|
||||
as a hard block.
|
||||
- **at 12h:** the setter re-reads the default against what has landed and asks
|
||||
whether it still holds and whether doubt remains. A stale default does not
|
||||
fire; new doubt makes it a hard block.
|
||||
- **at 24h:** the builder proceeds regardless, **as a PR**, stating the option
|
||||
chosen and the doubt that remains. Nothing merges by this; the human still
|
||||
gates the merge.
|
||||
- **past 24h:** triage picks the option, records it as a decision, and remains
|
||||
accountable. The operator may overturn it at merge.
|
||||
|
||||
A re-flag starts a new ladder. The rungs apply whatever `Default:` says,
|
||||
including a hard block. Active discussion still climbs the ladder; by
|
||||
contrast, the separate 7-day nudge resets on real activity. The machine
|
||||
observes the rungs but never sets, clears, or decides `needs-ruling`.
|
||||
|
||||
The flag stays up until agreement is *reached* — a human reply alone does not
|
||||
clear it — and its setter closes it out: records the ruling as a decision in
|
||||
one comment, removes the label, and returns the item to its flow in that same
|
||||
comment, never as a side effect. If the human disagrees that agreement was
|
||||
reached, the label goes back on. The reconciler refuses `state:needs-human`
|
||||
while it stands (the PR falls to `state:addressing` — the ball on the PR is
|
||||
the builder's, who carries the ruling in), and the staleness sweep skips it,
|
||||
because waiting on a human is legitimately quiet. Quiet, but not unwatched
|
||||
(#52, both surfaces): a flag set with no escalation comment from its setter
|
||||
is called out by the sweep — comment-only, scoped to the labeled event, the
|
||||
label never removed — and a ruling with no real activity for 7 days draws a
|
||||
comment-only nudge addressed to the decider, linking the escalation. The
|
||||
nudge carries no marker on purpose: the comment is itself activity, so it
|
||||
resets its own window and never repeats within a quiet week. Label churn is
|
||||
never activity, or the sweep would reset itself — and each surface's clock
|
||||
reads what exists on it: on a pull request, comments, reviews and commits;
|
||||
on an issue, comments alone. An assignment is the claim clock's fact, not
|
||||
the ruling's — claiming a flagged issue does not answer it, and buys the
|
||||
escalation no quiet (#284).
|
||||
|
||||
`offsite` is issue-only and records that a claimed issue's deliverable lives
|
||||
in another repository, where a closing reference cannot make a local open PR
|
||||
visible to the sweep (#68). The builder sets it in the same step that posts
|
||||
the cross-repo draft link, then clears it at handoff in the same comment that
|
||||
reports whether that PR merged or closed. The machine reads the flag and
|
||||
never writes it. It stops only the claim-reclaim clock: missing assignees are
|
||||
still flagged, queue-label conflicts and missing queue state are still
|
||||
repaired, and epic-completion and PR-side stale behavior are unchanged. The
|
||||
sweep tells the assignee once when every visible cross-referenced PR has
|
||||
closed; it only tells, and never clears the flag or changes the claim.
|
||||
|
||||
`attention` is issue-only and says a demand is parked on an issue for its
|
||||
assignee. Anyone who needs that assignee's hands — triage, the operator, or a
|
||||
sibling agent — sets it. The assignee alone clears it, as the first act of
|
||||
pickup together with a short comment; that removal is the acknowledgement
|
||||
and re-arms the flag for the next demand. If the session dies before the ack,
|
||||
the still-visible flag launches the next pickup instead. An unanswered flag
|
||||
is auditable evidence on the board.
|
||||
|
||||
The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
|
||||
with `needs-ruling`, and never substitutes for queue state. It pauses no
|
||||
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
|
||||
unanswered `attention` is exactly the silence the 48-hour reclaim should
|
||||
take. It is hand-set: the machine never sets `attention`, never assigns
|
||||
anyone to receive one, and never decides that one has been answered — the
|
||||
assignee's removal is the only ack. It writes the label in exactly one
|
||||
place, the derived `claimed` → `post-merge` transition below, and nowhere
|
||||
else; where it reads the flag it reads it to diagnose. The PR sweep comments
|
||||
when `attention` is put on a pull request, and the issue sweep comments when
|
||||
it is put on an issue with no assignee. Both diagnoses leave the label and
|
||||
assignees alone; the machine never infers the claim issue, decides that the
|
||||
demand was answered, or repairs either malformed shape.
|
||||
An `attention` issue without an assignee is therefore a board bug, not a
|
||||
demand; anyone may assign it or remove the flag. It never composes with
|
||||
`post-merge`, whose released claim has no assignee to answer the demand. The
|
||||
one machine-clear exception is the derived `claimed` → `post-merge`
|
||||
transition: releasing the assignee clears a carried `attention` in the same
|
||||
edit. A hand-created `post-merge` + `attention` composition is flagged, not
|
||||
rewritten.
|
||||
|
||||
The three signals are mutually distinct: `attention` means an assignee owes
|
||||
a move; `needs-ruling` means a human owes a decision under
|
||||
[the escalation contract and ladder](BUILDER.md#the-ruling-ask); and a bare
|
||||
`@`-mention is an FYI that demands nothing and remains perfectly fine. A
|
||||
demand that is itself a human decision carries `needs-ruling`, never both.
|
||||
This distinction records the
|
||||
[#16 missed-ruling incident](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
|
||||
and why the rejected mention poll is not returning: ordinary thread traffic
|
||||
re-arms mentions, but only the writer can declare that a move is owed (#83).
|
||||
|
||||
## Scope — which surface? (PRs and issues, any number)
|
||||
|
||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
|
||||
set is per-repo: PRs get theirs from changed paths via the labels workflow's
|
||||
scope job — an additive write only, so a label applied by hand or by an agent
|
||||
while the machine runs always survives it (#130) — and issues get theirs from
|
||||
triage. This file never enumerates a set — it is mirrored
|
||||
byte-identically into every governed repo, and any list it carried would be
|
||||
true in one repo and false in the rest (#104). The set for the repo you are
|
||||
standing in lives in the two places that are true wherever you read them: its
|
||||
`.github/labels.conf` (the definitions, one `name|color|description` row per
|
||||
scope) and its own `CONTRIBUTING.md`, beside the other repo-specific facts.
|
||||
|
||||
## Issue types
|
||||
|
||||
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
|
||||
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
|
||||
on a PR would say the same thing twice and drift.
|
||||
|
||||
## Maintenance
|
||||
|
||||
The labels workflow (issue #10) recomputes PR state statelessly on subscribed
|
||||
events plus a consumer-owned scheduled discovery sweep. Hourly is the
|
||||
recommended default when no other engine drives board state; relax it only as
|
||||
the transition classes with no other writer shrink. Manual dispatch both
|
||||
bootstraps this taxonomy idempotently and runs the operator's on-demand
|
||||
full-board reconcile. The sweep warns when the core taxonomy declares a label
|
||||
the repository lacks. The same workflow reconciles issue-flow labels on issue
|
||||
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
|
||||
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
|
||||
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.
|
||||
14
.ceremony/README.md
Normal file
14
.ceremony/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# .ceremony/ — the vendored doctrine mirror
|
||||
|
||||
Never edit these files ad hoc. The six manifest-listed doctrine files are
|
||||
byte-identical copies of
|
||||
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony) at 0.6.3, but
|
||||
stoke does not run `docs-sync` or re-diff the mirror in CI. Change doctrine
|
||||
upstream through its own flow, then re-vendor it here when the pin moves.
|
||||
|
||||
The labels doctrine is vendored manually from
|
||||
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony) at 0.6.3 because
|
||||
stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
|
||||
`docs-sync --fix` cannot run until or unless a future issue adopts the
|
||||
ceremony release-workflow pin; until then, doctrine updates must re-vendor
|
||||
the pinned manifest manually.
|
||||
219
.ceremony/RELEASES.md
Normal file
219
.ceremony/RELEASES.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# Release management
|
||||
|
||||
This file describes the release-management pattern available to governed
|
||||
repositories. Adoption is per repository and operator-ruled: a repository
|
||||
without version epics is not out of compliance. A repo-local roadmap is the
|
||||
map; each epic remains the source of truth for its own release. Where an older
|
||||
repo-local description differs from this file, this file governs.
|
||||
|
||||
## The ladder
|
||||
|
||||
Represent each planned release with one version epic. The epic is the working
|
||||
surface for that release: it states the goal, names the members, and records
|
||||
the ordered waves as checklists. Keep the machine-readable progress checklist
|
||||
under a heading matching `## Task list`, case-insensitively; the issue-flow
|
||||
sweep reads task rows there until the next heading when it decides whether to
|
||||
nudge triage about a completed epic. Other member or wave headings are not
|
||||
completion inputs.
|
||||
|
||||
Keep a short repo-local roadmap beside the epics. The roadmap shows the whole
|
||||
ladder and points to each working surface; it does not duplicate the live
|
||||
member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338)
|
||||
maps the ladder whose `0.1.2` working surface moved from the crufty ledger
|
||||
[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346).
|
||||
|
||||
## Gates
|
||||
|
||||
Each version epic declares `Blocked by <predecessor>`. Special ordering — a
|
||||
double gate or an out-of-chain gate — is written explicitly on that epic;
|
||||
there is no hidden global schedule. The epic carries `epic` and the
|
||||
repository's release label, with no queue label. Its `Blocked by` line is a
|
||||
declaration a human reads: shipping closes the predecessor, then triage opens
|
||||
the next window by hand as the first step of release-init. The issue-flow
|
||||
sweep does not promote version epics; automating that gate would require a
|
||||
separately specified change to its queue-category model.
|
||||
|
||||
The gate orders windows, not their contents. Members enter a release only by
|
||||
decision during release-init. The double gate on
|
||||
[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the
|
||||
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
||||
are worked examples of exceptions declared where they apply.
|
||||
|
||||
## The membership record
|
||||
|
||||
A release issue's `Blocked by` line answers the predecessor gate above and
|
||||
nothing else. Which issues are *in* the release is a separate record on the
|
||||
same issue, and the sweep reads it by heading (#343):
|
||||
|
||||
- the heading is literally `## Members`, matched case-insensitively, tolerant
|
||||
of any run of whitespace between the `##` and the word and of trailing
|
||||
whitespace after it, and the record runs to the next heading — the same
|
||||
shape `## Task list` already has;
|
||||
- one member per list row, under any Markdown list marker and only those:
|
||||
`-`, `*`, `+`, and 1 to 9 digits followed by `.` or `)` all open a row,
|
||||
because a row is whatever a reader sees as one — and a tenth digit opens
|
||||
nothing, CommonMark's ordered marker being at most nine digits, so
|
||||
`1234567890. #412` is narration and enrols no member. Indentation is bounded
|
||||
the same way: up to three spaces still open a row, four or more open nothing,
|
||||
a leading tab counting as four. The record is **flat** — one member per
|
||||
top-level row — and past that bound a line is not one: standing alone it is
|
||||
an indented code block, and under a row it is a sub-bullet annotating that
|
||||
member, and neither is a member itself. Below the bound it enrols, an
|
||||
indented row being the same bytes as a top-level one. The member is the
|
||||
row's first token after the list marker and an optional checkbox, and it is
|
||||
a bare local `#<number>`: `- #253` and `- [ ] #253` both enrol #253.
|
||||
Everything after that token is prose and contributes nothing, so a row is
|
||||
free to cite the PR that closed it, a sibling repository, or an issue it
|
||||
names as explicitly *not* a member;
|
||||
- a row whose first token is anything else — a qualified `repo#N`, a number
|
||||
with punctuation attached, or ordinary prose — contributes no member. The
|
||||
parse stays silent rather than guessing;
|
||||
- a qualified reference is never a member: a window is one repository's DAG,
|
||||
decided against one board read;
|
||||
- a row naming the release issue itself contributes no member. The sink is
|
||||
never one of its own members;
|
||||
- **there is no fallback to the gate.** A release issue with no members
|
||||
section enumerates no membership, is not a standing window, and draws no
|
||||
window flag. A repository whose epics predate this record gets silence,
|
||||
never a false flag, until its next release-init writes one.
|
||||
|
||||
Why a heading and not a marker phrase: the `Blocked by` parse unions every
|
||||
occurrence of its marker and runs each clause to a sentence terminator, which
|
||||
is the right error direction for a `blocked` issue and the wrong one for a
|
||||
release body that is mostly narration *about* its members. Why the first token
|
||||
and not every reference in the row: a real member row cites merged PRs, other
|
||||
repositories and explicit non-members, and reading the whole row enrols all of
|
||||
them.
|
||||
|
||||
The cost is named rather than hidden: a version epic maintains two lists — the
|
||||
`## Members` record and the `## Task list` progress view — and triage writes
|
||||
both in the same flip. The purchase is that the progress view stays a progress
|
||||
view, prose-rich and free to carry several issues in one row or to omit a
|
||||
member that is not in the build queue, while membership is a machine record
|
||||
with exactly one shape.
|
||||
|
||||
## Release-init
|
||||
|
||||
The predecessor closing and clearing the next epic's declared gate is the
|
||||
trigger, and today triage must notice it and open that window by hand.
|
||||
[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253)
|
||||
tracks the not-yet-shipped sweep announcement of that duty; do not treat the
|
||||
announcement as present until the consumer's pin carries it. Triage runs five
|
||||
steps:
|
||||
|
||||
1. Mint the epic's “to mint when this arc opens” list together with findings,
|
||||
deferred work, and discussion outcomes accumulated since the epic was
|
||||
written. Each member initially declares `Blocked by <the epic>`.
|
||||
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
|
||||
3. Write the waves into the epic body as checklists in claim order, with a
|
||||
separate verification lane and the progress view under `## Task list`, and
|
||||
write the window's membership under `## Members` — release-init is where
|
||||
that record is first written, and until it exists no window stands.
|
||||
4. Ask the operator to bless the order, then have triage open the first wave
|
||||
by applying the flip mechanics below. The operator's blessing is the one
|
||||
step this chain never automates.
|
||||
5. Ship through the repository's cut process, close the epic, and treat that
|
||||
close as the trigger for the next window.
|
||||
|
||||
heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges
|
||||
and shared-file contention visible before builders entered the queue. If init
|
||||
finds no work worth minting, the operator either folds the empty window into a
|
||||
later release or skips the version, recording that ruling on the epic before
|
||||
closing it unshipped.
|
||||
|
||||
## One primary window, declared parallel tracks
|
||||
|
||||
Run one primary release window by default. A cut takes whatever has landed, so
|
||||
interleaving unrelated windows blurs both the release story and the evidence
|
||||
behind it. Gates open windows; they do not silently admit members, so builders
|
||||
still see one deliberately ordered queue.
|
||||
|
||||
While a window stands — an open release-labeled issue whose membership record
|
||||
holds at least one open member — its members form a DAG whose sink is the
|
||||
release issue. Every member reaches that sink. Members declare only their
|
||||
immediate predecessors; ordering edges live on members, while the sink records
|
||||
membership only, in the record above and nowhere else; and the `ready` set is
|
||||
exactly the graph's current sources. Every close
|
||||
releases exactly its declared successors, and that whole set is concurrently
|
||||
claimable: a member may have multiple successors, while the collision rule
|
||||
already orders any that share a deliverable. Insertion re-points downstream
|
||||
edges rather than merely appending membership at the sink. It follows that
|
||||
every `ready` issue is a member. `epic` and `post-merge` issues are exempt
|
||||
because neither is claimable (#292).
|
||||
|
||||
A member that lands `post-merge` releases nothing: that exemption is about
|
||||
claimability, while a `post-merge` issue is still open and an open predecessor
|
||||
holds its successors, so every successor declaring on it stays held and the
|
||||
window stops advancing along that edge (#329).
|
||||
|
||||
**When a member reaches `post-merge` and any open declaration names it, triage
|
||||
splits the remainder**: mint a fresh issue carrying the outstanding criteria
|
||||
verbatim, naming its owner and its wake condition and citing the original, then
|
||||
close the original on what it delivered. Triage owns this because only triage
|
||||
mints work issues and `post-merge` is its completion queue (#329).
|
||||
|
||||
**The release edge is the original's close, never the remainder's.** Each
|
||||
successor's declaration names the original's number, so closing the new issue
|
||||
releases nothing (#329).
|
||||
|
||||
**Split only when an open declaration names the issue.** The trigger is a check
|
||||
rather than a judgement — run the blocker parse over every open `blocked` body
|
||||
and see whether this number appears — because an issue that strands nothing is
|
||||
`post-merge` working as intended (#329).
|
||||
|
||||
**Never close work out from under a builder.** Where the original is assigned,
|
||||
`claimed`, or carrying an open PR, amend its body to hand the outstanding
|
||||
criteria to the new issue and let its holder close it, so the release edge above
|
||||
is reached without taking the work from them (#329).
|
||||
|
||||
**Do not instead teach the blocker parse that `post-merge` counts as landed.**
|
||||
That promotes a successor while its predecessor still owes acceptance criteria,
|
||||
inverts a parser whose deliberate error direction is to hold or flag a reference
|
||||
it cannot read rather than release it, and needs label data a reference-state
|
||||
lookup does not carry (#329).
|
||||
|
||||
The operator may declare a parallel track at init when its footprint is
|
||||
disjoint from the primary window: another repository, another artifact, or
|
||||
provably non-overlapping clusters. The declaration names the boundary and any
|
||||
bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
||||
is the worked example: its app and artifact form a parallel track while its
|
||||
small crew-side bridge remains in the primary window.
|
||||
|
||||
## Flip mechanics
|
||||
|
||||
To admit a member, delete or rewrite its literal, parseable
|
||||
`Blocked by <the epic>` declaration and swap `blocked` to `ready` in the same
|
||||
edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads
|
||||
the raw marker text and still returns the reference. Never preserve history by
|
||||
negating the marker phrase — the parser unions declarations even when prose
|
||||
says they no longer apply. Preserve the history only after rewriting the
|
||||
marker into non-parseable prose, then verify that the parser returns an empty
|
||||
set for the release gate.
|
||||
|
||||
**The same flip adds the member's row to the release issue's membership
|
||||
record.** That write is not bookkeeping to catch up on later: the record is
|
||||
the only thing that makes the window stand, so a member flipped `ready`
|
||||
without a row is, to the sweep, an unblocked non-member — the exact state the
|
||||
window flag exists to report. Verify the flip by reading the record back and
|
||||
finding the new member's row in it (#343).
|
||||
|
||||
Release membership is a decision, never a sweep default. Triage performs each
|
||||
flip only after the operator blesses the wave; the issue-flow sweep may resolve
|
||||
ordinary issue dependencies, but it does not choose a release's contents.
|
||||
heavy-duty/crew#346 records the member-by-member flip that opened its first
|
||||
wave.
|
||||
|
||||
## The ledger pattern
|
||||
|
||||
When a release epic has become too crufty to remain a clear working surface,
|
||||
create a replacement and treat the old epic as a ledger. Do not close the old
|
||||
epic until every live member declaration points at the replacement and the
|
||||
blocker parser verifies the new set. Closing early can release every member
|
||||
that still names the old issue.
|
||||
|
||||
The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346)
|
||||
transition is the worked example: all member declarations were re-pointed and
|
||||
parse-verified before #162 closed; #162 remains the historical record while
|
||||
#346 is the release's working surface.
|
||||
164
.ceremony/REVIEWER.md
Normal file
164
.ceremony/REVIEWER.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# REVIEWER.md — the reviewer role
|
||||
|
||||
You are one voice on a panel. The panel's job is to converge — on an
|
||||
approval the human can trust, or on a precise statement of what is wrong.
|
||||
The machine reads only your **verdict**; humans read your reasons.
|
||||
|
||||
## The verdict doctrine
|
||||
|
||||
- **Every review ends in a verdict**: approve, or request changes. A
|
||||
comment-only review is a non-verdict — it does not say whether the round
|
||||
passed, the state machine treats it as not-approved, and the PR simply
|
||||
stalls. If you have an opinion, you have a verdict; commenting without one
|
||||
only wedges the flow.
|
||||
- **The verdict carries blockingness only; the body carries the feedback.**
|
||||
Non-blocking nits ride an **approval**, and the builder addresses them at
|
||||
their discretion. Anything blocking — including a question whose answer
|
||||
gates your approval — is **request changes**, saying exactly what
|
||||
unblocks it.
|
||||
- **Name what you could not verify, in the verdict body.** Say which checks
|
||||
you could not run and why, and what you relied on instead: CI, reading, or
|
||||
a narrower probe. An unstated environment gap reads as coverage — exactly
|
||||
the blind spot Kimi's [crew report](https://github.com/heavy-duty/crew/blob/main/kimi-bot-andresmgsl/assessment.md)
|
||||
identified for boxes without `node` or `shellcheck`.
|
||||
- An approval you would not defend to the human is a defect. You are not
|
||||
being asked to be agreeable; you are being asked to be right.
|
||||
|
||||
## What you review against
|
||||
|
||||
In order of authority:
|
||||
|
||||
1. **The issue's acceptance criteria** — the PR's `Closes #N`, its
|
||||
cross-repo `Part of <owner>/<repo>#N`, or its `Refs #N` when the issue
|
||||
body marks a criterion post-merge, names your spec. That last shape is
|
||||
not a defect: the issue directs it, triage owns that close, and a
|
||||
request-changes on the "missing" keyword enforces the bug the shape
|
||||
exists to fix — `Closes #137` closed its issue with a post-merge
|
||||
criterion unmet (#151). For a `Refs #N` body, also verify that no closing
|
||||
keyword immediately precedes `#N` anywhere in the body, even in prose
|
||||
explaining the hand close or inside a code span: GitHub used those exact
|
||||
shapes to close #209, #212 and #199 (#200, #218). The safe forms put the
|
||||
number first (`#N is closed by hand`) or omit it (`triage closes the issue
|
||||
by hand`). Check every
|
||||
criterion; a PR that ships less than the issue says is a request-changes
|
||||
even if the code is beautiful.
|
||||
2. **The repo's load-bearing constraints** — the rules bought with
|
||||
incidents (in ceremony itself: issue #1's constraint list; in a governed
|
||||
repo: its own CONTRIBUTING plus ceremony's README). A change that
|
||||
"simplifies away" a constraint gets request-changes with a link to the
|
||||
incident that made the rule.
|
||||
- **Verify a pinned consumer at its pin, not ceremony's `main`.** Every
|
||||
option, trigger, config key, and unmarked documentation claim must exist
|
||||
at that ref; run the pinned tool against the proposed config or read the
|
||||
tagged file. On [box#164](https://github.com/heavy-duty/box/pull/164),
|
||||
`0.1.0`'s `load_config` rejected `triage-actors=...` with
|
||||
`malformed label row` and `exit=1`. CI green on a conversion PR proves
|
||||
nothing about the new config: the base branch's workflow is what ran.
|
||||
- **Third-party actions never hold a write-capable token by default.** In
|
||||
any job whose token is write-capable (`packages: write`,
|
||||
`contents: write`, `id-token: write`, or one carrying deploy secrets),
|
||||
the default is a repo-owned script a test can drive. A third-party
|
||||
action may hold that token only if it comes from an **established
|
||||
publisher** — a real organization with maintenance history and more
|
||||
than one maintainer, not a memberless shell or a lone account shipping
|
||||
an unauditable `dist/` blob — and is **pinned by full commit SHA**. An
|
||||
action matching the incubator red-flag profile never holds a write
|
||||
token, however well it works. Read-only jobs: ordinary dependency
|
||||
judgement, SHA-pinning still required. This is bot-run infrastructure —
|
||||
no human watches runtime logs, so a compromised action's window is
|
||||
unbounded (incubator#53/#54; #216).
|
||||
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||
floor exist? do the failure cases actually fail?), then conventions.
|
||||
Changelog line present for behavior changes; comments carry why, not
|
||||
what.
|
||||
|
||||
**Verify over opine.** Run what can be run; construct the failing input; a
|
||||
test settles what a comment thread can't. A review that says "I ran X and
|
||||
saw Y" outranks one that says "this looks like it might".
|
||||
|
||||
## Where you review
|
||||
|
||||
- **A review request on you is your authorization** in any `heavy-duty` repo
|
||||
and on any fleet member's fork. You need no separate permission and do not
|
||||
wait for the repo to appear on a list: review is reversible
|
||||
read-plus-comment work, and the requester already decided it should happen.
|
||||
- **A request is authorization, not panel membership.** Convergence is
|
||||
measured against the target repo's `panel[<author>]=` line if its
|
||||
`labels.conf` defines one for the PR author, else its `panel=` line; minus
|
||||
the author in either case (#224). If you
|
||||
are requested off-panel, post the verdict anyway and say in its body that
|
||||
it is advisory; neither your silence nor your request-changes is a gate the
|
||||
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
|
||||
rig#112 showed why authorization and membership must not be conflated.
|
||||
- **Being requested is a wake condition of its own.** It is how work in a
|
||||
repo you have never heard of reaches you; a repo list finds only work in
|
||||
repos somebody thought to list.
|
||||
|
||||
## How you work the queue
|
||||
|
||||
- **Your queue is the API, not the search index.** Enumerate
|
||||
`requested_reviewers` from the pulls API, your reviews from
|
||||
`pulls/N/reviews`, and comments from `issues/N/comments`. Search lag left
|
||||
cast#143, incubator#25, and box#164 waiting, as Claude's
|
||||
[crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/assessment.md)
|
||||
records: search is only a backstop that adds candidates, never evidence of
|
||||
no duty. `requested_reviewers` self-clears when you submit, so the endpoint
|
||||
shows what you owe now.
|
||||
- **Every write is one-shot, keyed to (you, PR, head SHA).** Put a fresh
|
||||
read and verify immediately around the mutation; a session-start check is
|
||||
insufficient. If verification says it landed, stop even when the CLI
|
||||
looked unhappy. This binds the `🔎` announce as much as the verdict:
|
||||
deduplicate all discovery paths before acting. Duplicate verdicts on
|
||||
[#26](https://github.com/heavy-duty/ceremony/pull/26),
|
||||
[#29](https://github.com/heavy-duty/ceremony/pull/29), and
|
||||
[#39](https://github.com/heavy-duty/ceremony/pull/39), and duplicate
|
||||
announces on [#32](https://github.com/heavy-duty/ceremony/pull/32), bought
|
||||
the rule; do not answer a double-post with a third comment.
|
||||
- **Review each head in a throwaway checkout; keep the main clone clean.**
|
||||
Use a detached worktree per PR head and remove it after the verdict. A
|
||||
crashed build corrupted Claude's build clone in 2026-07-22
|
||||
([crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/knowledge.md));
|
||||
running another tree in the clone you keep risks the whole box.
|
||||
|
||||
## What you do not do
|
||||
|
||||
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||
above it, in a proposal where humans had their say. If you think the
|
||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||
proposal, while still reviewing the implementation against the spec as
|
||||
written. Spec changes go through triage, not through a review round.
|
||||
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||
human; only humans merge.
|
||||
- **Approve a moving target.** Your approval is of a specific head. If the
|
||||
builder pushes after your approval, GitHub stales it — that is correct,
|
||||
and the builder owes a re-request, not an assumption.
|
||||
|
||||
## The round rhythm
|
||||
|
||||
- Review the **whole PR at the current head** each round, not just the diff
|
||||
since your last comments — the fix for someone else's point can break
|
||||
yours.
|
||||
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||
reviews mid-address just churn the target).
|
||||
- A **draft carrying `state:addressing` is a fix round in progress**, not
|
||||
abandonment: an engine may convert a PR back to draft at round close so the
|
||||
builder's mid-round saves stop firing CI, and the flip back to ready is the
|
||||
builder's own act announcing the round is answered
|
||||
([BUILDER.md](BUILDER.md#the-review-round)).
|
||||
- Convergence = every panel verdict approves the current head, no
|
||||
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||
the panel's job is done.
|
||||
- Flag an unowned decision when it belongs to a human: org policy, published
|
||||
artifacts, secrets, prod, or any choice whose cost lands outside the PR. A
|
||||
disagreement within the panel is one instance, not the definition
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Argue a
|
||||
panel disagreement in the PR with evidence until one side concedes or the
|
||||
builder escalates; two reviewers pulling a builder in opposite directions
|
||||
without resolution is a panel failure, not a builder failure.
|
||||
`needs-ruling` is set by the **builder**, never by you: one accountable
|
||||
flag-setter per PR hands the human one consolidated question. State the
|
||||
unowned decision precisely enough for the builder to write
|
||||
[the canonical ruling ask](BUILDER.md#the-ruling-ask), including what
|
||||
stops and what continues ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50);
|
||||
[LABELS.md](LABELS.md)).
|
||||
165
.ceremony/TRIAGE.md
Normal file
165
.ceremony/TRIAGE.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# TRIAGE.md — the triage role
|
||||
|
||||
You are the only door work issues come through. Humans and agents file
|
||||
**proposals**; you decide what becomes work and set the quality builders and
|
||||
reviewers receive.
|
||||
|
||||
## Why this door exists
|
||||
|
||||
Proposals may be ambiguous; work issues may not: a builder must be able to
|
||||
execute one **without asking anything**. One accountable role keeps builders
|
||||
from guessing.
|
||||
|
||||
## Your inputs
|
||||
|
||||
- **Every open proposal** in the repo you serve.
|
||||
- **Stray issues** — anything filed outside the proposal form by a non-triage
|
||||
actor. Label it
|
||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||
substance into a proposal and close it, saying why. Route the work
|
||||
without shaming the filer.
|
||||
|
||||
## For each proposal, converge on exactly one outcome
|
||||
|
||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||
already shipped or tracked. Link the code, doc, or issue; mark answered.
|
||||
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
||||
answers would let you write the issue — then stop and wait. Do not mint an
|
||||
issue that carries the ambiguity forward; that just moves your job onto
|
||||
the builder.
|
||||
3. **Escalate.** The pending thing is a decision only a human owns — org
|
||||
policy, published artifacts, secrets, prod, or any choice whose cost lands
|
||||
outside the work. A panel deadlock is one instance, not the definition
|
||||
(#50 D11). Say precisely what the decision is, name the decider, and use
|
||||
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
||||
including its options, recommendation, blocked/continues statement, and
|
||||
reversible-only default rules (#50 D12–D13).
|
||||
The proposal is where humans decide; wait there. When the decision
|
||||
blocks something already on the board — an existing issue, or minted work
|
||||
a proposal's ruling gates — set `needs-ruling` on it too, so the board
|
||||
shows where the human's turn is; the issue keeps its queue label.
|
||||
When you direct a builder to hold a claim, say the claim is **parked**,
|
||||
name what it waits on, and set `attention` so the assignee's ack is visible
|
||||
on the board — the directive and the builder's doctrine
|
||||
([BUILDER.md](BUILDER.md#claiming)) must use one word.
|
||||
Immediately before asserting label-borne state in prose — a hold, a
|
||||
claim, a queue state, whether in a comment, a body header, or a
|
||||
`needs-ruling` ask — re-read that issue's **label events**
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
|
||||
comments: the answer often arrives as a label with no comment, and a
|
||||
write that re-read only the thread races it (#149, #151).
|
||||
Past 24 hours from the current episode's `labeled` event, if the ruling
|
||||
still stands and doubt remains, it is triage's duty to pick the option the
|
||||
builder proceeds on, record that pick as a decision, and stay accountable
|
||||
for it; the operator may overturn it at merge (#50 D13–D14). You set the
|
||||
flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
|
||||
agreement is reached, record the ruling as a decision in one comment,
|
||||
remove the label, and return the issue to its flow in that same comment;
|
||||
when that ruling or any directive or answered builder question delivers
|
||||
the assignee's next move in prose, set `attention` in the same comment on
|
||||
the assigned issue that owns the claim — never on the pull request, even
|
||||
when the comment lives there. Flagging an unassigned issue is a board bug,
|
||||
not a demand; repair the board rather than setting `attention`.
|
||||
This is not a substitute for minting work or for `needs-ruling`.
|
||||
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||
outcome; a zombie proposal is not.
|
||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||
the bar.
|
||||
|
||||
## The issue contract
|
||||
|
||||
Every issue you mint carries, in this order:
|
||||
|
||||
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||
abstraction, two backends", never "improve version handling".
|
||||
- **Context**: why this exists, with links — the proposal it came from,
|
||||
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||
rot), prior art in sibling repos.
|
||||
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||
open question, the issue is not ready to exist — go back to outcome 2 or 3.
|
||||
- **Tasks**: the steps, checkboxed, in order.
|
||||
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
|
||||
the builder's definition of done and the reviewer's review spec, verbatim.
|
||||
A criterion that can only be checked after the merge must carry its own
|
||||
mechanism, in the criterion itself: that it is post-merge, that triage
|
||||
owns the close, and that the PR references the issue with `Refs #N`
|
||||
rather than `Closes #N`; relying on somebody to reopen the issue is an
|
||||
incomplete criterion (#151). The merge moves the issue to `post-merge` and
|
||||
releases the claim. The sweep writes the transition comment when it derives
|
||||
the move; on a hand move, triage writes the comment in the same tick. In
|
||||
either case triage follows up with the remaining criteria, their owner, and
|
||||
the wake condition for completion.
|
||||
- **Test plan**: what proves it, including the cases that must fail.
|
||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||
epic organizes it. Name a cross-repo dependency the same way with its
|
||||
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
|
||||
cannot resolve it, so triage verifies it and flips the issue by hand.
|
||||
When a deliverable is already carried by an open `ready`, `claimed`, or
|
||||
`blocked` issue, the newer issue must declare an unconditional collision
|
||||
edge with `Blocked by #N`, naming the newest open carrier; there is no
|
||||
alternative for disjoint regions. This keeps every `ready` issue
|
||||
concurrently claimable and makes each close release one successor (#288).
|
||||
During a standing release window, every mint also gets a binary membership
|
||||
call in the same tick. A non-member names the release issue as its blocker
|
||||
in its own Dependencies. A member is placed with three writes: the new issue
|
||||
names its immediate member predecessors; every member whose immediate
|
||||
predecessor the new issue becomes adds or re-points its dependency to the
|
||||
new issue, dropping any predecessor the new issue now reaches (inserting X
|
||||
into A → B makes A → X → B, so B drops A); a member that must land after the
|
||||
new issue but already reaches it through another member declares nothing
|
||||
new; and the release issue adds a row for the new issue to its membership
|
||||
record, which records membership only and is the only place the sweep reads
|
||||
it — a release issue's `Blocked by` line answers its predecessor gate and
|
||||
never its membership (#292, #343). Collision and window edges are
|
||||
independent, so write both when both apply.
|
||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||
|
||||
The bar, stated once: **a competent builder who has read only this issue and
|
||||
the repo can succeed.** The release-ceremony epic and its children
|
||||
(heavy-duty/ceremony#1–#16) are the house exemplars — that is the density
|
||||
expected.
|
||||
|
||||
## Multi-issue work
|
||||
|
||||
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
||||
label) with the approach, decisions, constraints, and a dependency-ordered
|
||||
child checklist. Children reference the epic; that checklist is the progress
|
||||
view. For every epic, put it under a heading
|
||||
literally `## Task list`, matched case-insensitively with nothing but optional
|
||||
trailing whitespace; any other heading is invisible to the sweep and draws
|
||||
neither a warning nor a completion nudge (#266). Builders never pick the epic
|
||||
itself. Keep the checklist current — a stale epic misleads every scan.
|
||||
Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
||||
|
||||
## Backlog hygiene
|
||||
|
||||
- **Dedup before minting** — search issues *and* closed issues; extend or
|
||||
reopen before duplicating.
|
||||
- The issue-flow sweep flips `blocked` → `ready` when every named dependency
|
||||
lands, and flags a blocked issue whose dependency declaration is unreadable.
|
||||
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
|
||||
+ no activity → comment, unassign, restore `ready`.
|
||||
- `post-merge` is triage's completion queue, not a parked claim. Tick verified
|
||||
criteria and close under the criterion's existing contract. If corrective
|
||||
build work becomes necessary, move it to `ready` or mint a fresh `ready`
|
||||
issue: any builder claims from current `main`, the original builder has no
|
||||
special standing, and re-entry does not set `attention`.
|
||||
- Automation never guesses intent. Resolve the conflict comments it leaves on
|
||||
malformed queue states, and close or extend completed epics when nudged.
|
||||
- **Close obsolete issues** with the reason and a link to what obsoleted
|
||||
them. Every label on every open issue stays true; the board is only worth
|
||||
scanning if it does not lie.
|
||||
- **A lifted hold makes its body prose stale in the same instant, and the
|
||||
body is yours.** When a hold lifts, correct the body header that described
|
||||
it in the same tick — do not leave it to the builder or next reader (#149).
|
||||
|
||||
## What you never do
|
||||
|
||||
- Write code, review code, or build the thing yourself.
|
||||
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||
refusal into a vague issue to avoid saying no.
|
||||
- Mint a work issue to explore an idea — file a proposal instead.
|
||||
25
.forgejo/workflows/ci.yml
Normal file
25
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# CI: run the test suite on every pull request and on pushes to main, so
|
||||
# regressions are caught before they reach a release tag.
|
||||
#
|
||||
# Requirements:
|
||||
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
|
||||
# your runner actually advertises (common: docker, ubuntu-latest).
|
||||
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
steps:
|
||||
- name: Check out commit
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run tests
|
||||
run: npm ci && npm test && npm run check:governance
|
||||
49
.forgejo/workflows/labels-sweep.yml
Normal file
49
.forgejo/workflows/labels-sweep.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: labels-sweep
|
||||
on:
|
||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||
# when no other engine drives board state: the cron is then the sweep's only
|
||||
# wake for four transition classes — a review verdict landing (no
|
||||
# pull_request_review trigger on the labels caller), blocker:ci-red
|
||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
||||
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
|
||||
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
|
||||
# latency on those four while cutting nominal scheduled sweeps from four an
|
||||
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
|
||||
# discovery path. If another engine writes some of those transitions, only
|
||||
# the classes with no other writer bound the cadence; relax it only as that
|
||||
# list shrinks.
|
||||
schedule: [{cron: "0 * * * *"}]
|
||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
||||
# wakes this workflow with bootstrap=no on every board event, so the
|
||||
# declared input is part of the contract: a dispatch naming an undeclared
|
||||
# input is refused, and the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bootstrap:
|
||||
description: Bootstrap the label taxonomy before sweeping
|
||||
type: choice
|
||||
options: ["yes", "no"]
|
||||
default: "yes"
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
sweep:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@0.6.3
|
||||
with:
|
||||
# Pass the dispatch input through the workflow_call boundary
|
||||
# explicitly — a called workflow must not rely on reading the caller's
|
||||
# event inputs (ceremony#215 measured that failing). Empty (schedule)
|
||||
# maps to "no" explicitly, so a cron-woken sweep never re-upserts the
|
||||
# taxonomy.
|
||||
bootstrap: ${{ inputs.bootstrap || 'no' }}
|
||||
# If this repo's PR-facing labels caller is named anything but `labels`,
|
||||
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
|
||||
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
|
||||
# trigger) never count toward blocker:ci-red — a red trigger means "fix
|
||||
# the caller", which no PR edit can do (#208 reads it).
|
||||
34
.forgejo/workflows/labels.yml
Normal file
34
.forgejo/workflows/labels.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: labels
|
||||
on:
|
||||
pull_request_target:
|
||||
# Fork PRs; these carry the head/draft/review facts state:* derives from.
|
||||
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
|
||||
# synchronize re-derives on every push. review_requested/review_request_removed
|
||||
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
|
||||
# blocker:unrequested when the panel is asked.
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
||||
# consumer pinned to 0.1.0 omits this block.
|
||||
issues:
|
||||
# Narrowed (#199) to the actions carrying a queue-state change the hourly
|
||||
# cron cannot wait one cadence for: opened → the mint→needs-triage check,
|
||||
# closed → the blocker-closes→ready self-heal, edited → a body rewrite of the
|
||||
# `Blocked by #N` declaration the sweep parses, reopened → a closed issue
|
||||
# re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned —
|
||||
# validation + the 48h claim clock, caught within one cadence, and
|
||||
# labeled/unlabeled were the issues-churn source. The handoff wake is
|
||||
# pull_request_target:labeled, not issues, so this leaves it intact.
|
||||
types: [opened, closed, edited, reopened]
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
labels:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.6.3
|
||||
# If the sweep caller below is named anything but labels-sweep.yml,
|
||||
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
|
||||
# dogfood does (self-labels-sweep.yml).
|
||||
|
|
@ -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
12
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
34
.github/ISSUE_TEMPLATE/proposal.yml
vendored
Normal 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
75
.github/ISSUE_TEMPLATE/work-order.yml
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# The issue contract (TRIAGE.md "The issue contract") as a form, so every
|
||||
# minted issue lands with the same bones and a later parser (#18) can key on
|
||||
# stable section headings. Consistency, not a gate: the form applies no
|
||||
# labels — queue labels are triage's explicit act (LABELS.md), and #18's
|
||||
# sweep is what catches non-triage authors, so the form must not pre-judge
|
||||
# that (issue #24, decision 2).
|
||||
name: Work order (triage only)
|
||||
description: >-
|
||||
The issue contract (TRIAGE.md) as a form. Only triage mints work issues —
|
||||
everyone else files a proposal. Triage may still compose by hand when
|
||||
the form fights it: `stoke issue create --body-file` bypasses forms and stays
|
||||
legitimate for the triage identity.
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >-
|
||||
**The title names the deliverable** — "lib/version.sh — one version
|
||||
abstraction, two backends", never "improve version handling". The
|
||||
bar for everything below, stated once: a competent builder who has
|
||||
read only this issue and the repo can succeed (TRIAGE.md). Labels
|
||||
(type, `scope:*`, `ready`/`blocked`) are your explicit act after
|
||||
minting — this form applies none.
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: >-
|
||||
Why this exists, with links — the proposal it came from, the code
|
||||
it touches (permalinks at a pinned SHA, so line references cannot
|
||||
rot), prior art in sibling repos.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: spec
|
||||
attributes:
|
||||
label: Spec
|
||||
description: >-
|
||||
Decisions made, not options listed. If the spec still has an open
|
||||
question, the issue is not ready to exist.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: tasks
|
||||
attributes:
|
||||
label: Tasks
|
||||
description: The steps, checkboxed, in order.
|
||||
placeholder: "- [ ] the first step"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: acceptance-criteria
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: >-
|
||||
Checkboxed, verifiable, and honest — these become the builder's
|
||||
definition of done and the reviewer's review spec, verbatim.
|
||||
placeholder: "- [ ] a verifiable outcome"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: test-plan
|
||||
attributes:
|
||||
label: Test plan
|
||||
description: What proves it, including the cases that must fail.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: dependencies
|
||||
attributes:
|
||||
label: Dependencies
|
||||
description: >-
|
||||
`Blocked by #N` / `Blocks #N`, and `Part of #E` when an epic
|
||||
organizes it — or state "None".
|
||||
validations:
|
||||
required: true
|
||||
16
.github/labeler.yml
vendored
Normal file
16
.github/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Path → scope:* map consumed by ceremony's additive labels-scope job.
|
||||
"scope:cli":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["src/**"]
|
||||
"scope:packaging":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml", "package.json", "package-lock.json", "CHANGELOG.md", "changelog.d/**"]
|
||||
"scope:manifests":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["manifests/**"]
|
||||
"scope:ci":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"]
|
||||
"scope:docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["*.md", "docs/**"]
|
||||
7
.github/labels.conf
vendored
Normal file
7
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
||||
triage-actors=claude-bot-andresmgsl
|
||||
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
|
||||
scope:packaging|C5DEF5|scripts/, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces
|
||||
scope:manifests|C5DEF5|manifests/ — the fleet repo registry data
|
||||
scope:ci|C5DEF5|.forgejo/workflows/, .github/, and .ceremony/ — the test, label, and governance gates
|
||||
scope:docs|C5DEF5|root Markdown and docs/ — the prose contract
|
||||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# AGENTS.md — start at .ceremony/
|
||||
|
||||
This repository is governed by
|
||||
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony). Read
|
||||
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||
42
CHANGELOG.md
Normal file
42
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Changelog
|
||||
|
||||
## 1.5.0 — 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23).
|
||||
- Added guided proposal and triage work-order forms, with the contributor guide linking directly to proposal intake. (#50).
|
||||
|
||||
### Changed
|
||||
|
||||
- Enforced the exact Forgejo ceremony source and version across governance records and workflow pins. (#36).
|
||||
- Publish release assets and the matching changelog section through stoke's credential-safe CLI. (#54).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep package-lock metadata aligned with the package version and guard against future drift. (#43).
|
||||
- Cover every governed repository surface and reject unmapped tracked paths or divergent scope names. (#48).
|
||||
|
||||
## 1.4.0 — 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- Added `repo clone` with branch, depth, remote-name, and destination options; credentials stay in ephemeral Git configuration instead of command arguments, remotes, or repository configuration. (#13, #14).
|
||||
- Added the stoke brand system and a package file allowlist so published packages include the CLI, README, license, design guide, and vector logo without tests or generated binary assets. (#12, #16).
|
||||
- Added CI for pull requests and pushes to `main`. (#17).
|
||||
- Added `issue show`, `issue comment`, raw `--json` output for read commands, and commit-pinned `pr review --commit`. (#20).
|
||||
- Added repeatable labels to `issue create`, with label validation before issue creation. (#26, #29).
|
||||
- Added ceremony governance automation, vendored role doctrine, configuration validation, the reviewer panel, and path-based scope labels. (#30, #31).
|
||||
- Added `repo create --owner` for organization-owned repositories while preserving the authenticated-user default. (#24, #35).
|
||||
- Added `release create --asset` and `release upload`, including streamed uploads, single-asset renaming, release IDs, attached-file listings, backpressure handling, and partial-failure reporting. (#25, #37).
|
||||
- Added private Debian registry credentials without exposing them in source URLs. (#1, #38).
|
||||
|
||||
### Changed
|
||||
|
||||
- `auth login` now requests least-privilege scopes by default; use `--full-scopes` for the former broad set or `--scopes` for an explicit set. This is a behavior change. (#9, #19).
|
||||
- Bumped the ceremony workflow pin from 0.6.1 to 0.6.3, re-vendored all six doctrine files from that release, and updated the mirror's pin record. (#39).
|
||||
|
||||
### Fixed
|
||||
|
||||
- `install-apt` now fails clearly when registry metadata is absent instead of continuing into an opaque apt failure. (#18).
|
||||
- Debian installs preserve signature verification across transient failures, refuse unverifiable registries by default, and allow an HTTPS-only bypass solely through the explicit opt-in for the known sqv failure. (#1, #38).
|
||||
56
CONTRIBUTING.md
Normal file
56
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Contributing to stoke
|
||||
|
||||
Read the role instructions in [`.ceremony/`](.ceremony/) first. This file
|
||||
contains only facts specific to the stoke repository.
|
||||
|
||||
## Review panel
|
||||
|
||||
| Identity |
|
||||
| --- |
|
||||
| `codex-bot-andresmgsl` |
|
||||
| `glm-bot-andresmgsl` |
|
||||
| `claude-bot-andresmgsl` |
|
||||
| `kimi-bot-andresmgsl` |
|
||||
|
||||
The `panel=` line in [`.github/labels.conf`](.github/labels.conf) is
|
||||
authoritative; this table is its human-readable restatement.
|
||||
|
||||
## The `scope:*` set
|
||||
|
||||
| Scope | Covers |
|
||||
| --- | --- |
|
||||
| `scope:cli` | `src/` — the command surface (`cli.js`, `api.js`, and `config.js`) |
|
||||
| `scope:packaging` | `scripts/`, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces |
|
||||
| `scope:manifests` | `manifests/` — fleet repository registry data |
|
||||
| `scope:ci` | `.forgejo/workflows/`, `.github/`, and `.ceremony/` — the test, label, and governance gates |
|
||||
| `scope:docs` | root Markdown and `docs/` — the prose contract |
|
||||
|
||||
These names and path descriptions restate the scope rows in
|
||||
[`.github/labels.conf`](.github/labels.conf).
|
||||
|
||||
## Drills
|
||||
|
||||
Stoke currently practises no drill. The repository has no `drills/` directory;
|
||||
releases use stoke's own tag-driven
|
||||
[`.forgejo/workflows/release.yml`](.forgejo/workflows/release.yml), not the
|
||||
ceremony release flow; and the maintainer-created `blocker:drill-pending` label
|
||||
(label 252) carries no open item.
|
||||
|
||||
If the open ruling in #27 adopts ceremony's release workflow, that work must
|
||||
update this section. This description of the current route takes no position on
|
||||
the ruling's alternatives.
|
||||
|
||||
## Code conventions
|
||||
|
||||
- The package uses CommonJS: `package.json` declares `"type": "commonjs"`, and
|
||||
files under `src/` use `require()` and `module.exports` rather than ESM.
|
||||
- Tests use Node's built-in `node:test` module, live in `test/*.test.js`, and run
|
||||
through `node --test`; there is no separate test framework dependency.
|
||||
- `package.json` requires Node.js 22.12.0 or newer.
|
||||
- Shell programs under `scripts/` use the `#!/usr/bin/env bash` shebang.
|
||||
|
||||
## Work issue ownership
|
||||
|
||||
Only triage mints work issues; anyone may file a
|
||||
[proposal](https://forgejo.heavyduty.builders/heavy-duty/stoke/issues/new?template=proposal.yml),
|
||||
which triage converts or refuses.
|
||||
401
README.md
401
README.md
|
|
@ -13,13 +13,38 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman
|
|||
|
||||
### With apt (Debian/Ubuntu — recommended)
|
||||
|
||||
The package is published to the Debian registry of the forge itself. One-time setup:
|
||||
The package is published to the public Debian registry of the forge itself.
|
||||
One-time setup:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash
|
||||
```
|
||||
|
||||
or manually. First add the forge's registry as an apt source:
|
||||
If a private registry or a `FORGE_URL=` override requires authentication,
|
||||
download the installer and supply a Forgejo login and package-readable token:
|
||||
|
||||
```bash
|
||||
export FORGE_USER=your-forgejo-login
|
||||
read -rsp 'Forgejo token: ' FORGE_TOKEN && echo && export FORGE_TOKEN
|
||||
curl -fsSLo /tmp/stoke-install-apt.sh \
|
||||
https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh
|
||||
sudo --preserve-env=FORGE_USER,FORGE_TOKEN bash /tmp/stoke-install-apt.sh
|
||||
unset FORGE_TOKEN
|
||||
```
|
||||
|
||||
The authenticated path keeps credentials out of the source URL in a
|
||||
root-readable apt auth file. To configure that file manually before adding the
|
||||
source:
|
||||
|
||||
```bash
|
||||
sudo install -d -m 0755 /etc/apt/auth.conf.d
|
||||
sudo install -m 0600 /dev/null /etc/apt/auth.conf.d/forgejo-heavy-duty.conf
|
||||
printf 'machine forgejo.heavyduty.builders\nlogin %s\npassword %s\n' \
|
||||
"$FORGE_USER" "$FORGE_TOKEN" \
|
||||
| sudo tee /etc/apt/auth.conf.d/forgejo-heavy-duty.conf >/dev/null
|
||||
```
|
||||
|
||||
Then add the forge's registry as an apt source:
|
||||
|
||||
```bash
|
||||
sudo install -d /etc/apt/keyrings
|
||||
|
|
@ -46,7 +71,28 @@ sudo apt-get update && sudo apt-get install stoke
|
|||
|
||||
Upgrades then arrive through regular `apt-get upgrade`. `install-apt.sh` performs all of the above, adding the NodeSource repository only when no already-configured apt source offers a new-enough nodejs.
|
||||
|
||||
Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9) currently reject the signature Forgejo generates for its Debian registry (an upstream signing bug). `install-apt.sh` detects this and falls back to a `[trusted=yes]` source — integrity then relies on HTTPS to the forge. The script prefers the signed source, so setups heal automatically once the forge is fixed.
|
||||
Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9)
|
||||
may reject signatures produced by affected Forgejo versions. By default,
|
||||
`install-apt.sh` refuses that signature failure and removes the Forge source;
|
||||
authentication, network, and all other update failures are also fatal and never
|
||||
disable verification.
|
||||
|
||||
If the installer reports the known `sqv` parsing failure and you deliberately
|
||||
accept HTTPS-only integrity without OpenPGP verification, opt in on a second
|
||||
run:
|
||||
|
||||
```bash
|
||||
export STOKE_ALLOW_UNVERIFIED_APT=1
|
||||
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash
|
||||
unset STOKE_ALLOW_UNVERIFIED_APT
|
||||
```
|
||||
|
||||
For a private registry, re-run the downloaded installer with
|
||||
`sudo --preserve-env=FORGE_USER,FORGE_TOKEN,STOKE_ALLOW_UNVERIFIED_APT` instead.
|
||||
|
||||
This exact opt-in is the only path in the installer that writes a
|
||||
`[trusted=yes]` source. The installer prints the security trade-off again when
|
||||
it takes that path.
|
||||
|
||||
As a fallback, each release also has the `.deb` attached for direct install: `sudo dpkg -i stoke_<version>_all.deb`.
|
||||
|
||||
|
|
@ -80,8 +126,8 @@ Example stored config:
|
|||
```json
|
||||
{
|
||||
"url": "https://forgejo.heavyduty.builders",
|
||||
"login": "kimi-reviewer-andresmgsl",
|
||||
"username": "kimi-reviewer-andresmgsl",
|
||||
"login": "kimi-bot-andresmgsl",
|
||||
"username": "kimi-bot-andresmgsl",
|
||||
"email": "andres+4@heavyduty.builders",
|
||||
"token": "<sha1>",
|
||||
"tokenId": 42
|
||||
|
|
@ -104,6 +150,8 @@ Example stored config:
|
|||
|
||||
## Commands
|
||||
|
||||
Read commands (`auth status`, `repo list`, `issue list`, `issue show`, `pr list`, `pr show`, `release list`, `release view`, `label list`, `branch list`, `org repos`, `org team list`, `org team member-list`, `user list`, `user show`) accept a `--json` flag that prints the raw API response, pretty-printed, instead of the human-readable format — useful for scripting.
|
||||
|
||||
### Global options
|
||||
|
||||
```text
|
||||
|
|
@ -125,6 +173,8 @@ Options:
|
|||
-t, --token <token> use an existing personal access token instead of generating one
|
||||
--token-file <path> read an existing personal access token from a file
|
||||
--token-name <name> name for the generated token
|
||||
--full-scopes grant full read/write access on all non-admin scopes
|
||||
--scopes <csv> comma-separated list of scopes for the generated token
|
||||
```
|
||||
|
||||
Interactive example:
|
||||
|
|
@ -137,7 +187,7 @@ stoke auth login
|
|||
Non-interactive example using environment variables:
|
||||
|
||||
```bash
|
||||
export STOKE_USERNAME='kimi-reviewer-andresmgsl'
|
||||
export STOKE_USERNAME='kimi-bot-andresmgsl'
|
||||
export STOKE_PASSWORD='...'
|
||||
stoke auth login
|
||||
```
|
||||
|
|
@ -146,7 +196,7 @@ Password file example (avoids shell history and special-character issues):
|
|||
|
||||
```bash
|
||||
chmod 600 /run/secrets/stoke-password
|
||||
stoke auth login -n kimi-reviewer-andresmgsl --password-file /run/secrets/stoke-password
|
||||
stoke auth login -n kimi-bot-andresmgsl --password-file /run/secrets/stoke-password
|
||||
```
|
||||
|
||||
Existing token example:
|
||||
|
|
@ -159,9 +209,31 @@ Flow:
|
|||
|
||||
1. Calls `GET /api/v1/user` to verify credentials and resolve the canonical `login` name.
|
||||
2. Calls `POST /api/v1/users/{login}/tokens` to generate a personal access token.
|
||||
3. Requests the standard non-admin scopes: `read/write` for `activitypub`, `issue`, `misc`, `organization`, `package`, `repository`, and `user`.
|
||||
3. Requests the default least-privilege scopes (see "Token scopes" below).
|
||||
4. Writes the token, token id, user details and URL to the config file.
|
||||
|
||||
Token scopes:
|
||||
|
||||
By default the generated token is least-privilege, covering the common
|
||||
issue/PR/repository commands:
|
||||
|
||||
- `read:issue`, `write:issue` — issues, PR comments/reviews, labels
|
||||
- `read:repository`, `write:repository` — repositories, branches, releases, collaborators, pull requests
|
||||
- `read:user` — `auth status`, `user list`, `user show`
|
||||
- `read:organization` — `org repos`, `org team list`, `org team member-list`
|
||||
|
||||
Organization administration (`org create`, `org avatar`, `org team create`,
|
||||
`org team member-add`, `org team member-remove`) and package publishing need
|
||||
broader access. Pass `--full-scopes` for the previous all-scopes behavior
|
||||
(`read`/`write` on `activitypub`, `issue`, `misc`, `organization`, `package`,
|
||||
`repository`, `user`), or `--scopes <csv>` for a custom list:
|
||||
|
||||
```bash
|
||||
stoke auth login --scopes read:issue,write:issue,read:repository
|
||||
```
|
||||
|
||||
The scopes the token was created with are printed after a successful login.
|
||||
|
||||
### `stoke auth logout`
|
||||
|
||||
Revoke the stored token remotely and delete the local config.
|
||||
|
|
@ -196,13 +268,62 @@ stoke auth status
|
|||
|
||||
Calls `GET /api/v1/user` with the stored token.
|
||||
|
||||
### `stoke repo clone`
|
||||
|
||||
Clone a repository from the configured Forgejo instance using the stored credentials.
|
||||
|
||||
```text
|
||||
Arguments:
|
||||
[directory] destination directory (default: repository name)
|
||||
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
--branch <branch> checkout this branch instead of the default branch
|
||||
--depth <depth> create a shallow clone with the given history depth
|
||||
--origin <name> name for the created remote (default: origin)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke repo clone -o heavy-duty -r stoke
|
||||
stoke repo clone -o heavy-duty -r stoke ~/src/stoke --depth 1
|
||||
```
|
||||
|
||||
The stored token is handed to git ephemerally through environment-based config (`GIT_CONFIG_*`): it never appears in the remote URL, on the command line, or in the cloned repository's `.git/config`. Git's output is streamed directly and its exit status is forwarded, so failures behave exactly like a plain `git clone`.
|
||||
|
||||
### `stoke repo sync`
|
||||
|
||||
Fast-forward an undiverged imported repository from an upstream Git URL.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
--from <upstream-url> upstream Git URL (required)
|
||||
--branch <branch> branch to synchronize (default: the forge repository's default branch)
|
||||
--tags also create new upstream tags
|
||||
--dry-run report branch and tag moves without pushing
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke repo sync -o heavy-duty -r box \
|
||||
--from https://github.com/heavy-duty/box.git
|
||||
stoke repo sync -o heavy-duty -r box \
|
||||
--from https://github.com/heavy-duty/box.git --tags --dry-run
|
||||
```
|
||||
|
||||
The command fetches both branch tips into an ephemeral bare repository and pushes only when the forge tip is an ancestor of the upstream tip. It refuses a diverged tree with both commit SHAs and never offers a force option. With `--tags`, new upstream tags are created; an existing forge tag that points elsewhere is reported and left untouched, and the command exits non-zero after applying any other safe moves. The stored Forgejo token uses the same environment-only Git authentication as `repo clone` and is never written to an argument, remote, or Git config.
|
||||
|
||||
This verb deliberately does not merge diverged trees, configure Forgejo pull-mirrors, or copy releases. Follow ceremony's live `docs/UPSTREAM-SYNC.md` procedure for a diverged tree; import a scheduled read-only repository as a pull-mirror; compose release mirroring from `release create` and `release upload`.
|
||||
|
||||
### `stoke repo create`
|
||||
|
||||
Create a new repository for the authenticated user.
|
||||
Create a new repository for the authenticated user or an organization.
|
||||
|
||||
```text
|
||||
Options:
|
||||
--name <name> repository name (required)
|
||||
-o, --owner <owner> repository owner (authenticated user or organization)
|
||||
-d, --description <description> repository description
|
||||
--private make the repository private
|
||||
--public make the repository public
|
||||
|
|
@ -216,9 +337,13 @@ Example:
|
|||
```bash
|
||||
stoke repo create --name stoke-test --private \
|
||||
-d "Test repository created via stoke"
|
||||
stoke repo create -o heavy-duty --name shared-project --private
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/user/repos`.
|
||||
When `--owner` is omitted or names the authenticated user (case-insensitively),
|
||||
calls `POST /api/v1/user/repos`. For another owner, calls
|
||||
`POST /api/v1/orgs/{owner}/repos`; Forgejo returns `403` when the caller cannot
|
||||
create repositories for that organization.
|
||||
|
||||
### `stoke repo list`
|
||||
|
||||
|
|
@ -347,7 +472,7 @@ Options:
|
|||
Example used to move the heavy-duty repositories into the `heavy-duty` organization:
|
||||
|
||||
```bash
|
||||
stoke repo transfer -o kimi-reviewer-andresmgsl -r box --to heavy-duty
|
||||
stoke repo transfer -o kimi-bot-andresmgsl -r box --to heavy-duty
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/transfer`.
|
||||
|
|
@ -366,7 +491,7 @@ Options:
|
|||
```
|
||||
|
||||
```bash
|
||||
stoke issue list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
||||
stoke issue list -o kimi-bot-andresmgsl -r box -s all -l 0
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
|
||||
|
|
@ -383,13 +508,54 @@ Options:
|
|||
-b, --body <body> issue body (markdown)
|
||||
--body-file <path> read the issue body from a file
|
||||
--assignee <username...> assign the issue to one or more users
|
||||
--label <name...> apply one or more labels by name
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke issue create -o heavy-duty -r stoke -t "Ship v2" --body-file body.md
|
||||
stoke issue create -o heavy-duty -r stoke -t "Ship v2" --body-file body.md \
|
||||
--label ready --label enhancement
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/issues`.
|
||||
Requested label names are resolved through the paginated repository label list,
|
||||
then their numeric IDs are included in `POST /api/v1/repos/{owner}/{repo}/issues`.
|
||||
An unknown label fails before the issue is created.
|
||||
|
||||
### `stoke issue show`
|
||||
|
||||
Show details of an issue.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-n, --number <number> issue number (required)
|
||||
--json print raw JSON instead of human-readable output
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke issue show -o heavy-duty -r stoke -n 10
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/issues/{number}`.
|
||||
|
||||
### `stoke issue comment`
|
||||
|
||||
Add a comment to an issue. Body is required (whitespace-only is rejected). When both `-b` and `--body-file` are set, **`--body-file` wins**.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-n, --number <number> issue number (required)
|
||||
-b, --body <body> comment body (markdown; required unless --body-file)
|
||||
--body-file <path> read the comment body from a file (wins over -b)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke issue comment -o heavy-duty -r stoke -n 10 -b "Confirmed."
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/issues/{number}/comments`.
|
||||
|
||||
### `stoke pr list`
|
||||
|
||||
|
|
@ -404,7 +570,7 @@ Options:
|
|||
```
|
||||
|
||||
```bash
|
||||
stoke pr list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
||||
stoke pr list -o kimi-bot-andresmgsl -r box -s all -l 0
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
|
||||
|
|
@ -500,14 +666,180 @@ Options:
|
|||
--event <event> approve|approved, request-changes|request_changes, comment (required)
|
||||
-b, --body <body> review body (markdown; required for request-changes and comment)
|
||||
--body-file <path> read the review body from a file (wins over -b)
|
||||
--commit <sha> commit SHA the review applies to (sent as commit_id)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke pr review -o heavy-duty -r stoke -n 3 --event approve -b "Ship it."
|
||||
stoke pr review -o heavy-duty -r stoke -n 3 --event request-changes --body-file notes.md
|
||||
stoke pr review -o heavy-duty -r stoke -n 3 --event approve --commit 9fceb02
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. Prints the review URL when the forge returns one.
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. `--commit` is sent as `commit_id`; when omitted, no `commit_id` is sent. Prints the review URL when the forge returns one.
|
||||
|
||||
### `stoke release list`
|
||||
|
||||
List releases in a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-l, --limit <number> maximum releases to display (default: 50; use 0 for all)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke release list -o heavy-duty -r stoke
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/releases` and auto-paginates.
|
||||
|
||||
### `stoke release view`
|
||||
|
||||
Show the release for a tag, including its notes and attached assets. Each asset
|
||||
line includes its filename, size in bytes and download URL.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
--tag <tag> tag name of the release (required)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke release view -o heavy-duty -r stoke --tag v1.2.1
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}`.
|
||||
|
||||
### `stoke release create`
|
||||
|
||||
Create a release. If the tag does not exist yet, Forgejo creates it from `--target` (or the repository default branch). When both `-b` and `--body-file` are set, **`--body-file` wins**.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
--tag <tag> tag name for the release (required)
|
||||
--target <ref> branch or commit the tag is created from (default: default branch)
|
||||
-t, --title <title> release title (default: the tag name)
|
||||
-b, --body <body> release notes (markdown)
|
||||
--body-file <path> read the release notes from a file (wins over -b)
|
||||
--asset <path> attach an asset (repeatable)
|
||||
--asset-name <name> override the uploaded filename (exactly one asset)
|
||||
--draft create as a draft release
|
||||
--prerelease mark as a prerelease
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke release create -o heavy-duty -r stoke --tag v1.3.0 \
|
||||
--body-file release-notes.md --asset dist/stoke_1.3.0_all.deb
|
||||
```
|
||||
|
||||
The command prints the numeric release id, tag and URL. It calls
|
||||
`POST /api/v1/repos/{owner}/{repo}/releases`, then uploads each asset. If an
|
||||
upload fails, the release and any assets that already landed are kept; every
|
||||
asset is attempted, the command names successes and failures, and exits
|
||||
non-zero.
|
||||
|
||||
### `stoke release upload`
|
||||
|
||||
Attach one or more assets to an existing release. `--asset-name` overrides the
|
||||
uploaded filename and is valid only when exactly one `--asset` is supplied.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
--tag <tag> tag name of the existing release (required)
|
||||
--asset <path> asset to upload (required, repeatable)
|
||||
--asset-name <name> override the uploaded filename (exactly one asset)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke release upload -o heavy-duty -r stoke --tag v1.3.0 \
|
||||
--asset dist/checksums.txt --asset dist/stoke_1.3.0_all.deb
|
||||
```
|
||||
|
||||
The command resolves the tag once with
|
||||
`GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}`, then uploads each file
|
||||
to the release's numeric-id asset endpoint. It attempts every asset and exits
|
||||
non-zero if any upload fails.
|
||||
|
||||
### `stoke label list`
|
||||
|
||||
List labels in a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-l, --limit <number> maximum labels to display (default: 50; use 0 for all)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke label list -o heavy-duty -r stoke
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/labels` and auto-paginates.
|
||||
|
||||
### `stoke label create`
|
||||
|
||||
Create a label in a repository. The color is validated (6 hex digits, with or without a leading `#`) before any network call.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
--name <name> label name (required)
|
||||
--color <color> label color, 6 hex digits (required)
|
||||
-d, --description <description> label description
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke label create -o heavy-duty -r stoke --name release --color 0E8A16 \
|
||||
-d "Release flow and version/packaging work"
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/labels`.
|
||||
|
||||
### `stoke label delete`
|
||||
|
||||
Delete a label from a repository, by `--id` or `--name` (exactly one is required; passing both is rejected).
|
||||
|
||||
```bash
|
||||
stoke label delete -o heavy-duty -r stoke --name needs-triage
|
||||
```
|
||||
|
||||
Calls `DELETE /api/v1/repos/{owner}/{repo}/labels/{id}`. A `--name` is resolved to an id via the repository label list first.
|
||||
|
||||
### `stoke label add`
|
||||
|
||||
Add labels to an issue or pull request (PRs are issues as far as labels are concerned).
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-n, --number <number> issue or pull request number (required)
|
||||
--name <name...> one or more label names (required)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke label add -o heavy-duty -r stoke -n 12 --name release scope:cli
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/issues/{number}/labels`. Names are resolved to ids first; an unknown name fails with `Label not found`.
|
||||
|
||||
### `stoke label remove`
|
||||
|
||||
Remove labels from an issue or pull request.
|
||||
|
||||
```bash
|
||||
stoke label remove -o heavy-duty -r stoke -n 12 --name needs-triage
|
||||
```
|
||||
|
||||
Calls `DELETE /api/v1/repos/{owner}/{repo}/issues/{number}/labels/{id}` once per label.
|
||||
|
||||
### `stoke branch list`
|
||||
|
||||
|
|
@ -521,7 +853,7 @@ Options:
|
|||
```
|
||||
|
||||
```bash
|
||||
stoke branch list -o kimi-reviewer-andresmgsl -r box
|
||||
stoke branch list -o kimi-bot-andresmgsl -r box
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
|
||||
|
|
@ -541,8 +873,8 @@ Options:
|
|||
Example:
|
||||
|
||||
```bash
|
||||
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u andres --permission admin
|
||||
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u dan --permission admin
|
||||
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u andres --permission admin
|
||||
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u dan --permission admin
|
||||
```
|
||||
|
||||
Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`.
|
||||
|
|
@ -678,7 +1010,7 @@ Options:
|
|||
```
|
||||
|
||||
```bash
|
||||
stoke org team member-remove --team-id 1 -u kimi-reviewer-andresmgsl
|
||||
stoke org team member-remove --team-id 1 -u kimi-bot-andresmgsl
|
||||
```
|
||||
|
||||
Calls `DELETE /api/v1/teams/{id}/members/{username}`.
|
||||
|
|
@ -709,6 +1041,33 @@ stoke user show -u andres
|
|||
|
||||
Calls `GET /api/v1/users/{username}`.
|
||||
|
||||
### `stoke api`
|
||||
|
||||
Make an authenticated request to any Forgejo API endpoint and print the JSON response. The escape hatch for everything stoke does not wrap yet — pass the endpoint path without the `/api/v1` prefix.
|
||||
|
||||
```text
|
||||
Arguments:
|
||||
<endpoint> endpoint path starting with / (required)
|
||||
|
||||
Options:
|
||||
-X, --method <method> GET, POST, PUT, PATCH or DELETE
|
||||
(default: GET, or POST when --input is given)
|
||||
--input <json> JSON request body, inline or @path to read from a file
|
||||
--paginate fetch all pages (GET endpoints returning a JSON array);
|
||||
overrides any limit/page in the endpoint
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke api /user
|
||||
stoke api "/repos/heavy-duty/stoke/pulls?state=closed" --paginate
|
||||
stoke api /repos/heavy-duty/stoke/issues/12/comments --input '{"body":"hi"}'
|
||||
stoke api /repos/heavy-duty/stoke/contents/CHANGELOG.md --input @payload.json
|
||||
```
|
||||
|
||||
Calls `{METHOD} /api/v1{endpoint}` with the stored token. The endpoint must start with `/`; the method, `--paginate` + non-GET, `GET` + `--input` (a GET cannot carry a body), and malformed `--input` JSON are all rejected before any network call.
|
||||
|
||||
**Security:** `stoke api` is a full authenticated passthrough — it does anything the stored token is allowed to do. Never interpolate untrusted strings (issue titles, PR bodies, user input) into the endpoint or `--input`; treat every call like the credential it carries.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
|
|
@ -773,7 +1132,7 @@ Release checklist:
|
|||
|
||||
## Verification: heavy-duty repository imports
|
||||
|
||||
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
|
||||
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-bot-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
|
||||
|
||||
| Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
1
changelog.d/57.md
Normal file
1
changelog.d/57.md
Normal 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
1
changelog.d/60.md
Normal 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
1
changelog.d/62.md
Normal 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
1
changelog.d/63.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Normalize Debian package payload modes independently of the builder's umask. (#63).
|
||||
1
changelog.d/64.md
Normal file
1
changelog.d/64.md
Normal 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
1
changelog.d/65.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Continue batch imports after one repository cannot resolve its source token, while reporting that item as failed. (#65).
|
||||
|
|
@ -19,7 +19,7 @@ The file has four pages:
|
|||
| `03 · Landing — Mobile` | 390px responsive landing |
|
||||
| `04 · Assets & Components` | Command-group icon set (24px line) + generated-asset gallery |
|
||||
|
||||

|
||||
> Renders live in the [Figma file](https://www.figma.com/design/q6bYh7pRChTbg0diKAVrcS/stoke-Brand-Landing) — see "Why the raster assets are not committed here" below.
|
||||
|
||||
## Concept
|
||||
|
||||
|
|
@ -76,17 +76,39 @@ wordmark. Vector source in [`assets/logo-mark.svg`](../assets/logo-mark.svg).
|
|||
All imagery generated with **Artlist** (Seedream 5.0 Pro for stills, Kling 1.6 for the
|
||||
ambient loop) and embedded in the Figma file.
|
||||
|
||||
| File | What |
|
||||
| Asset | Where it lives |
|
||||
| --- | --- |
|
||||
| `assets/hero-forge.png` | 2048×878 · 21:9 hero backdrop — glowing coals, hot steel, sparks |
|
||||
| `assets/operator-hand.png` | 3:2 — a hand stoking coals (philosophy section) |
|
||||
| `assets/ember-texture.png` | 21:9 — molten ember bed (CTA / section backdrops) |
|
||||
| `assets/ember-loop.mp4` | 5s ambient ember loop (bonus motion asset) |
|
||||
| `assets/logo-mark.svg` | Ember spark logo mark (vector) |
|
||||
| `assets/preview-landing.png` | Full desktop landing render |
|
||||
| `assets/preview-brand.png` | Brand system page render |
|
||||
| `assets/logo-mark.svg` | **In this repo** — ember spark logo mark, vector, 1.4 kB |
|
||||
| Hero backdrop · 2048×878 21:9 — glowing coals, hot steel, sparks | Figma, page 04 |
|
||||
| Operator's hand stoking coals · 3:2 (philosophy section) | Figma, page 04 |
|
||||
| Molten ember bed · 21:9 (CTA / section backdrops) | Figma, page 04 |
|
||||
| 5s ambient ember loop (motion) | Figma, page 04 |
|
||||
| Desktop landing render · brand system render | Figma, pages 02 / 01 |
|
||||
|
||||
### Why the raster assets are not committed here
|
||||
|
||||
The first version of this change committed all of them — **23 MB of PNG and MP4
|
||||
into a CLI repo.** That was a mistake, and it was mine. Two concrete costs:
|
||||
|
||||
- `stoke`'s `.git` is ~23 MB; the assets would have **doubled it**, permanently.
|
||||
Git history is forever, so a marketing render committed today is still being
|
||||
cloned by every contributor in five years.
|
||||
- `package.json` had no `files` whitelist, so `npm pack` shipped the working
|
||||
directory. Measured: **the tarball went from ~100 kB to 23.7 MB** — a 200×
|
||||
bloat delivered to every user of a command-line tool, for images none of them
|
||||
will ever look at.
|
||||
|
||||
Binaries that exist to be *looked at* belong where people look at them: the Figma
|
||||
file, which is linked at the top and is the source of truth anyway. The vector
|
||||
logo stays because it is 1.4 kB of text, diffs cleanly, and is the one asset the
|
||||
project itself might need to render.
|
||||
|
||||
This change also adds the missing `files` whitelist to `package.json`, so the
|
||||
published tarball now contains only `src/` and the docs a user needs — a
|
||||
pre-existing packaging bug that shipping the test suite had been hiding.
|
||||
|
||||
## Notes
|
||||
|
||||
- This PR was opened with `stoke pr create` — the tool designing its own storefront.
|
||||
- Nothing here changes the CLI; it adds a `docs/` design record + `assets/`.
|
||||
- Nothing here changes the CLI. It adds a `docs/` design record, one vector
|
||||
asset, and a packaging fix.
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.2.1",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stoke",
|
||||
"version": "1.2.1",
|
||||
"version": "1.5.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -1,9 +1,10 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.2.1",
|
||||
"version": "1.5.0",
|
||||
"description": "CLI for the heavy-duty forge (Forgejo)",
|
||||
"main": "src/cli.js",
|
||||
"scripts": {
|
||||
"check:governance": "node scripts/check-governance.js",
|
||||
"test": "node --test",
|
||||
"start": "node src/cli.js"
|
||||
},
|
||||
|
|
@ -28,5 +29,12 @@
|
|||
},
|
||||
"bin": {
|
||||
"stoke": "src/cli.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"docs/DESIGN.md",
|
||||
"assets/logo-mark.svg"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
73
scripts/changelog-section.sh
Executable file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env bash
|
||||
# Vendored from heavy-duty/ceremony 0.6.3:
|
||||
# lib/changelog.sh (changelog_section + changelog_section_problem)
|
||||
# bin/changelog-section
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
changelog_section() {
|
||||
awk -v ver="$2" '
|
||||
/^## / { if (found) exit; found = ($2 == ver); next }
|
||||
found && !body && /^[[:space:]]*$/ { next }
|
||||
found { body = 1; print }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
changelog_section_problem() {
|
||||
local file="$1" ver="$2" notes problem
|
||||
|
||||
if ! awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$file"; then
|
||||
printf "no section for '%s'\n" "$ver"
|
||||
return 1
|
||||
fi
|
||||
|
||||
[ "$ver" = "Unreleased" ] && return 0
|
||||
|
||||
notes="$(changelog_section "$file" "$ver")"
|
||||
if ! printf '%s\n' "$notes" | awk '/^[[:space:]]*[-*][[:space:]]/ { found = 1; exit } END { exit !found }'; then
|
||||
printf "section '%s' has no entries — a heading is not an entry\n" "$ver"
|
||||
return 1
|
||||
fi
|
||||
|
||||
problem="$(
|
||||
printf '%s\n' "$notes" | awk '
|
||||
/^### / {
|
||||
if (heading != "" && !entry) {
|
||||
reported = 1
|
||||
print heading
|
||||
exit
|
||||
}
|
||||
heading = $0
|
||||
entry = 0
|
||||
next
|
||||
}
|
||||
heading != "" && /^[[:space:]]*[-*][[:space:]]/ { entry = 1 }
|
||||
END {
|
||||
if (!reported && heading != "" && !entry) print heading
|
||||
}
|
||||
'
|
||||
)"
|
||||
if [ -n "$problem" ]; then
|
||||
printf "section '%s' has an empty heading: '%s'\n" "$ver" "$problem"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ver="${1:-}"
|
||||
changelog="${2:-CHANGELOG.md}"
|
||||
if [ -z "$ver" ]; then
|
||||
echo "usage: changelog-section.sh <version> [<changelog>]" >&2
|
||||
exit 2
|
||||
fi
|
||||
[ -f "$changelog" ] || {
|
||||
echo "changelog-section: no such file: $changelog" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then
|
||||
echo "changelog-section: $changelog has no publishable section for '$ver'" >&2
|
||||
printf 'changelog-section: %s\n' "$diagnosis" >&2
|
||||
exit 1
|
||||
fi
|
||||
notes="$(changelog_section "$changelog" "$ver")"
|
||||
printf '%s\n' "$notes"
|
||||
152
scripts/check-governance.js
Normal file
152
scripts/check-governance.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const UNSCOPED_PATHS = [
|
||||
'.gitignore', // Repository plumbing has no product surface.
|
||||
'assets/logo-mark.svg', // One legacy brand asset does not justify a scope taxonomy.
|
||||
'test/*.test.js', // Tests inherit the scope of the production surface changed beside them.
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
config: path.join(process.cwd(), '.github', 'labels.conf'),
|
||||
apiUrl: process.env.FORGE_API_URL
|
||||
|| `${(process.env.FORGE_SERVER_URL || 'https://forgejo.heavyduty.builders').replace(/\/$/, '')}/api/v1`,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag !== '--config' && flag !== '--api-url') {
|
||||
throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error(`${flag} requires a value`);
|
||||
options[flag === '--config' ? 'config' : 'apiUrl'] = value;
|
||||
index += 1;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseConfig(contents, filename) {
|
||||
let panel;
|
||||
let triageActors = [];
|
||||
const scopes = [];
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
if (line.startsWith('panel=')) {
|
||||
if (panel) throw new Error(`duplicate panel line in ${filename}`);
|
||||
panel = line.slice('panel='.length).trim().split(/\s+/).filter(Boolean);
|
||||
if (panel.length === 0) throw new Error(`panel must name at least one reviewer in ${filename}`);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('triage-actors=')) {
|
||||
triageActors = line.slice('triage-actors='.length).trim().split(/\s+/).filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
const fields = line.split('|');
|
||||
if (fields.length !== 3 || !fields[0] || !/^[0-9A-Fa-f]{6}$/.test(fields[1]) || !fields[2]) {
|
||||
throw new Error(`malformed label row: ${line} in ${filename}`);
|
||||
}
|
||||
scopes.push(line);
|
||||
}
|
||||
if (!panel) throw new Error(`missing panel= line in ${filename}`);
|
||||
|
||||
const identities = [...new Set([...panel, ...triageActors])];
|
||||
for (const login of identities) {
|
||||
if (!/^[A-Za-z0-9-]+$/.test(login)) {
|
||||
throw new Error(`malformed login in ${filename}: ${login}`);
|
||||
}
|
||||
}
|
||||
return { identities, scopes };
|
||||
}
|
||||
|
||||
function parseLabeler(contents, filename) {
|
||||
const mappings = new Map();
|
||||
let current;
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const label = line.match(/^"([^"]+)":$/);
|
||||
if (label) {
|
||||
current = label[1];
|
||||
if (mappings.has(current)) throw new Error(`duplicate scope mapping in ${filename}: ${current}`);
|
||||
mappings.set(current, []);
|
||||
continue;
|
||||
}
|
||||
const matcher = line.match(/^\s+- any-glob-to-any-file:\s*(\[[^\n]+\])$/);
|
||||
if (!matcher) continue;
|
||||
if (!current) throw new Error(`scope matcher has no label in ${filename}`);
|
||||
let globs;
|
||||
try {
|
||||
globs = JSON.parse(matcher[1]);
|
||||
} catch {
|
||||
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
||||
}
|
||||
if (!Array.isArray(globs) || globs.length === 0 || globs.some((glob) => typeof glob !== 'string')) {
|
||||
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
||||
}
|
||||
mappings.get(current).push(...globs);
|
||||
}
|
||||
for (const [label, globs] of mappings) {
|
||||
if (globs.length === 0) throw new Error(`scope mapping has no globs in ${filename}: ${label}`);
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
function validateScopeNames(scopes, mappings) {
|
||||
const configNames = new Set(scopes.map((scope) => scope.split('|', 1)[0]));
|
||||
const labelerNames = new Set(mappings.keys());
|
||||
const onlyInConfig = [...configNames].filter((name) => !labelerNames.has(name)).sort();
|
||||
const onlyInLabeler = [...labelerNames].filter((name) => !configNames.has(name)).sort();
|
||||
const errors = [];
|
||||
if (onlyInConfig.length > 0) errors.push(`scope names only in labels.conf: ${onlyInConfig.join(', ')}`);
|
||||
if (onlyInLabeler.length > 0) errors.push(`scope names only in labeler.yml: ${onlyInLabeler.join(', ')}`);
|
||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||
}
|
||||
|
||||
function validateTrackedPaths(mappings) {
|
||||
let tracked;
|
||||
try {
|
||||
tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
|
||||
} catch (error) {
|
||||
throw new Error(`cannot enumerate tracked paths: ${error.message}`);
|
||||
}
|
||||
const globs = [...mappings.values()].flat();
|
||||
const uncovered = tracked.filter((filename) => (
|
||||
!UNSCOPED_PATHS.some((glob) => path.matchesGlob(filename, glob))
|
||||
&& !globs.some((glob) => path.matchesGlob(filename, glob))
|
||||
));
|
||||
if (uncovered.length > 0) {
|
||||
throw new Error(`tracked paths have no scope mapping: ${uncovered.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateIdentities(apiUrl, identities) {
|
||||
for (const login of identities) {
|
||||
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, { redirect: 'manual' });
|
||||
} catch (error) {
|
||||
throw new Error(`${login}: fetch failed: ${error.message}`);
|
||||
}
|
||||
if (response.status !== 200) throw new Error(`${login}: HTTP ${response.status} from ${endpoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const contents = fs.readFileSync(options.config, 'utf8');
|
||||
const { identities, scopes } = parseConfig(contents, options.config);
|
||||
const labeler = path.join(path.dirname(options.config), 'labeler.yml');
|
||||
const mappings = parseLabeler(fs.readFileSync(labeler, 'utf8'), labeler);
|
||||
validateScopeNames(scopes, mappings);
|
||||
validateTrackedPaths(mappings);
|
||||
await validateIdentities(options.apiUrl, identities);
|
||||
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`governance: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
# Usage:
|
||||
# ./scripts/install-apt.sh
|
||||
# FORGE_URL=... OWNER=... ./scripts/install-apt.sh # non-default instance
|
||||
# STOKE_ALLOW_UNVERIFIED_APT=1 ./scripts/install-apt.sh # explicit HTTPS-only opt-in
|
||||
#
|
||||
# Run as root or as a user with sudo.
|
||||
|
||||
|
|
@ -18,12 +19,27 @@ FORGE_URL="${FORGE_URL:-https://forgejo.heavyduty.builders}"
|
|||
OWNER="${OWNER:-heavy-duty}"
|
||||
DISTRIBUTION="${DISTRIBUTION:-stable}"
|
||||
COMPONENT="${COMPONENT:-main}"
|
||||
FORGE_USER="${FORGE_USER:-}"
|
||||
FORGE_TOKEN="${FORGE_TOKEN:-}"
|
||||
ALLOW_UNVERIFIED="${STOKE_ALLOW_UNVERIFIED_APT:-}"
|
||||
# Where apt configuration lives; overridable so tests can run against a
|
||||
# throwaway directory instead of the real /etc/apt.
|
||||
APT_ETC="${STOKE_APT_ETC:-/etc/apt}"
|
||||
|
||||
KEYRING="$APT_ETC/keyrings/forgejo-$OWNER.asc"
|
||||
LIST="$APT_ETC/sources.list.d/forgejo-$OWNER.list"
|
||||
AUTH="$APT_ETC/auth.conf.d/forgejo-$OWNER.conf"
|
||||
|
||||
if { [ -n "$FORGE_USER" ] && [ -z "$FORGE_TOKEN" ]; } \
|
||||
|| { [ -z "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; }; then
|
||||
echo "error: FORGE_USER and FORGE_TOKEN must be set together" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$ALLOW_UNVERIFIED" ] && [ "$ALLOW_UNVERIFIED" != "1" ]; then
|
||||
echo "error: STOKE_ALLOW_UNVERIFIED_APT must be unset or exactly 1" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
|
|
@ -31,6 +47,18 @@ if [ "$(id -u)" -ne 0 ]; then
|
|||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
CURL_AUTH=()
|
||||
if [ -n "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; then
|
||||
forge_host="${FORGE_URL#*://}"
|
||||
forge_host="${forge_host%%/*}"
|
||||
$SUDO install -d -m 0755 "$APT_ETC/auth.conf.d"
|
||||
$SUDO install -m 0600 /dev/null "$AUTH"
|
||||
printf 'machine %s\nlogin %s\npassword %s\n' \
|
||||
"$forge_host" "$FORGE_USER" "$FORGE_TOKEN" \
|
||||
| $SUDO tee "$AUTH" >/dev/null
|
||||
CURL_AUTH=(--netrc-file "$AUTH")
|
||||
fi
|
||||
|
||||
update_only_source() {
|
||||
$SUDO apt-get update \
|
||||
-o Dir::Etc::sourcelist="$1" \
|
||||
|
|
@ -80,25 +108,59 @@ ensure_nodejs_source() {
|
|||
}
|
||||
|
||||
echo "Adding APT source for $FORGE_URL/$OWNER ..."
|
||||
$SUDO install -d -m 0755 "$APT_ETC/keyrings"
|
||||
curl -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null
|
||||
$SUDO install -d -m 0755 "$APT_ETC/keyrings" "$APT_ETC/sources.list.d"
|
||||
$SUDO curl "${CURL_AUTH[@]}" -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null
|
||||
echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
|
||||
| $SUDO tee "$LIST" >/dev/null
|
||||
# tee inherits our umask; apt's unprivileged _apt user must be able to
|
||||
# read these.
|
||||
$SUDO chmod 0644 "$KEYRING" "$LIST"
|
||||
|
||||
# Fail fast with a clear message when the registry has no package published
|
||||
# yet: without a Release file, `apt-get update` would only fail with a
|
||||
# generic "repository does not have a Release file" error. A definitive 404
|
||||
# is fatal; any other curl outcome (e.g. a network hiccup) is left for
|
||||
# apt-get update to report.
|
||||
RELEASE_URL="$FORGE_URL/api/packages/$OWNER/debian/dists/$DISTRIBUTION/Release"
|
||||
if [ "$($SUDO curl "${CURL_AUTH[@]}" -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then
|
||||
echo "error: no stoke package has been published to the $OWNER Debian registry yet" >&2
|
||||
echo "($RELEASE_URL returned 404)." >&2
|
||||
echo "Install stoke via npm or manually instead — see the README." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo
|
||||
# currently produces for its Debian registry (malformed Ed25519 MPI encoding
|
||||
# in the upstream signing library). Try the properly signed source first so
|
||||
# this heals automatically once the forge is fixed; otherwise fall back to
|
||||
# [trusted=yes] — package integrity then relies on HTTPS to our own forge.
|
||||
if ! update_only_source "$LIST"; then
|
||||
echo
|
||||
echo "WARNING: signature verification failed (known Forgejo registry issue" >&2
|
||||
echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2
|
||||
echo "security is provided by HTTPS to $FORGE_URL." >&2
|
||||
echo
|
||||
# currently produces for its Debian registry. The cause is recorded on #1; it
|
||||
# is not the key algorithm, because the registry serves an RSA-2048 signing
|
||||
# key (`gpg --list-packets` on repository.key reports `algo 1` with a
|
||||
# 2048-bit pkey[0]). Try the properly signed source first so this heals
|
||||
# automatically once the forge is fixed. Only the exact live sqv failure, plus
|
||||
# the user's exact opt-in, permits an unverified source; auth, network, and
|
||||
# other failures must leave verification enabled and retain apt's diagnostic.
|
||||
if update_output="$(update_only_source "$LIST" 2>&1)"; then
|
||||
printf '%s\n' "$update_output"
|
||||
else
|
||||
update_status=$?
|
||||
if ! grep -Fqi '/usr/bin/sqv' <<<"$update_output" \
|
||||
|| ! grep -Fqi 'Malformed MPI' <<<"$update_output"; then
|
||||
printf '%s\n' "$update_output" >&2
|
||||
exit "$update_status"
|
||||
fi
|
||||
if [ "$ALLOW_UNVERIFIED" != "1" ]; then
|
||||
$SUDO rm -f "$LIST"
|
||||
echo "error: apt could not verify the Forgejo registry signature." >&2
|
||||
echo "On sqv-based apt, the known cause is that sqv-based apt cannot parse" >&2
|
||||
echo "the Forgejo registry signature, although gpgv-based apt accepts it." >&2
|
||||
echo "No apt source was left behind." >&2
|
||||
echo "If you knowingly accept HTTPS-only integrity, re-run with" >&2
|
||||
echo "STOKE_ALLOW_UNVERIFIED_APT=1 to disable OpenPGP verification." >&2
|
||||
exit "$update_status"
|
||||
fi
|
||||
echo >&2
|
||||
echo "WARNING: OpenPGP signature verification is disabled for the Forgejo" >&2
|
||||
echo "registry at $FORGE_URL. You explicitly accepted HTTPS-only integrity" >&2
|
||||
echo "by setting STOKE_ALLOW_UNVERIFIED_APT=1." >&2
|
||||
echo >&2
|
||||
echo "deb [trusted=yes] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
|
||||
| $SUDO tee "$LIST" >/dev/null
|
||||
$SUDO chmod 0644 "$LIST"
|
||||
|
|
|
|||
|
|
@ -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
49
scripts/publish-release.sh
Executable 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
|
||||
138
src/api.js
138
src/api.js
|
|
@ -12,11 +12,15 @@
|
|||
*/
|
||||
|
||||
const pkg = require('../package.json');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
// Repository migrations clone the full source repository and can legitimately
|
||||
// take minutes, so they get a much longer budget.
|
||||
const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
// Release assets can be much larger than JSON API payloads, so uploads get a
|
||||
// separate budget while retaining the standard timeout for ordinary calls.
|
||||
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
class ForgejoClient {
|
||||
constructor(baseUrl, token = null) {
|
||||
|
|
@ -94,6 +98,47 @@ class ForgejoClient {
|
|||
return data;
|
||||
}
|
||||
|
||||
async uploadRequest(endpoint, form, { timeout = UPLOAD_TIMEOUT_MS } = {}) {
|
||||
const url = `${this.baseUrl}/api/v1${endpoint}`;
|
||||
const headers = this.headers();
|
||||
delete headers['Content-Type'];
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') {
|
||||
throw new Error(`Upload to ${this.baseUrl} timed out after ${timeout / 1000}s`);
|
||||
}
|
||||
throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data?.message || data?.raw || `HTTP ${res.status}`;
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
get(endpoint) {
|
||||
return this.request('GET', endpoint);
|
||||
}
|
||||
|
|
@ -123,8 +168,14 @@ class ForgejoClient {
|
|||
return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
|
||||
}
|
||||
|
||||
async createRepo(payload) {
|
||||
return this.post('/user/repos', payload);
|
||||
async createRepo(payload, owner) {
|
||||
if (!owner) return this.post('/user/repos', payload);
|
||||
|
||||
const authenticatedUser = await this.get('/user');
|
||||
if (owner.toLowerCase() === authenticatedUser.login.toLowerCase()) {
|
||||
return this.post('/user/repos', payload);
|
||||
}
|
||||
return this.post(`/orgs/${encodeURIComponent(owner)}/repos`, payload);
|
||||
}
|
||||
|
||||
async migrateRepo(payload) {
|
||||
|
|
@ -134,9 +185,20 @@ class ForgejoClient {
|
|||
async getAll(endpoint, params = {}) {
|
||||
const pageSize = 50;
|
||||
const all = [];
|
||||
// The pager owns limit/page: a caller-supplied pair must be overridden,
|
||||
// not duplicated — a duplicated limit pins the page size the server
|
||||
// honors first and can truncate or loop the walk.
|
||||
const queryIndex = endpoint.indexOf('?');
|
||||
const path = queryIndex === -1 ? endpoint : endpoint.slice(0, queryIndex);
|
||||
const baseQuery = new URLSearchParams(queryIndex === -1 ? '' : endpoint.slice(queryIndex + 1));
|
||||
baseQuery.delete('limit');
|
||||
baseQuery.delete('page');
|
||||
for (let page = 1; page <= 1000; page += 1) {
|
||||
const query = new URLSearchParams({ ...params, limit: String(pageSize), page: String(page) }).toString();
|
||||
const items = await this.get(`${endpoint}?${query}`);
|
||||
const query = new URLSearchParams(baseQuery);
|
||||
for (const [key, value] of Object.entries(params)) query.set(key, value);
|
||||
query.set('limit', String(pageSize));
|
||||
query.set('page', String(page));
|
||||
const items = await this.get(`${path}?${query.toString()}`);
|
||||
if (!Array.isArray(items) || items.length === 0) break;
|
||||
all.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
|
|
@ -159,6 +221,14 @@ class ForgejoClient {
|
|||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload);
|
||||
}
|
||||
|
||||
async getIssue(owner, repo, index) {
|
||||
return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}`);
|
||||
}
|
||||
|
||||
async createIssueComment(owner, repo, index, body) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
|
||||
}
|
||||
|
||||
async listPullRequests(owner, repo, opts = {}) {
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts);
|
||||
}
|
||||
|
|
@ -175,11 +245,13 @@ class ForgejoClient {
|
|||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
|
||||
}
|
||||
|
||||
async createPullRequestReview(owner, repo, index, event, body) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, {
|
||||
async createPullRequestReview(owner, repo, index, event, body, { commitId } = {}) {
|
||||
const payload = {
|
||||
event,
|
||||
body: body || '',
|
||||
});
|
||||
};
|
||||
if (commitId) payload.commit_id = commitId;
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, payload);
|
||||
}
|
||||
|
||||
async mergePullRequest(owner, repo, index, payload) {
|
||||
|
|
@ -190,6 +262,58 @@ class ForgejoClient {
|
|||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts);
|
||||
}
|
||||
|
||||
async listReleases(owner, repo, opts = {}) {
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, opts);
|
||||
}
|
||||
|
||||
async getReleaseByTag(owner, repo, tag) {
|
||||
return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`);
|
||||
}
|
||||
|
||||
async createRelease(owner, repo, payload) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, payload);
|
||||
}
|
||||
|
||||
async uploadReleaseAsset(owner, repo, releaseId, filePath, name) {
|
||||
const form = new FormData();
|
||||
let file;
|
||||
try {
|
||||
file = await fs.openAsBlob(filePath);
|
||||
} catch (err) {
|
||||
throw new Error(`Could not read asset file ${filePath}: ${err.message}`);
|
||||
}
|
||||
form.append('attachment', file, name);
|
||||
const query = new URLSearchParams({ name });
|
||||
return this.uploadRequest(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(releaseId)}/assets?${query}`,
|
||||
form,
|
||||
);
|
||||
}
|
||||
|
||||
async listLabels(owner, repo, opts = {}) {
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts);
|
||||
}
|
||||
|
||||
async createLabel(owner, repo, payload) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, payload);
|
||||
}
|
||||
|
||||
async deleteLabel(owner, repo, id) {
|
||||
return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels/${id}`);
|
||||
}
|
||||
|
||||
// Pull requests are issues as far as labels are concerned, so these two
|
||||
// serve both surfaces.
|
||||
async addIssueLabels(owner, repo, index, labelIds) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels`, {
|
||||
labels: labelIds,
|
||||
});
|
||||
}
|
||||
|
||||
async removeIssueLabel(owner, repo, index, labelId) {
|
||||
return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels/${labelId}`);
|
||||
}
|
||||
|
||||
async addCollaborator(owner, repo, username, permission) {
|
||||
return this.request('PUT', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`, {
|
||||
permission,
|
||||
|
|
|
|||
750
src/cli.js
750
src/cli.js
|
|
@ -3,10 +3,12 @@
|
|||
const { Command, InvalidArgumentError } = require('commander');
|
||||
const readline = require('node:readline');
|
||||
const fs = require('node:fs');
|
||||
const { execSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { execSync, spawnSync } = require('node:child_process');
|
||||
const { stdin: input, stdout: output } = require('node:process');
|
||||
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
|
||||
const { ForgejoClient } = require('./api');
|
||||
const { syncRepository } = require('./repo-sync');
|
||||
|
||||
const pkg = require('../package.json');
|
||||
|
||||
|
|
@ -84,12 +86,67 @@ function parseId(value) {
|
|||
return n;
|
||||
}
|
||||
|
||||
function parseDepth(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new InvalidArgumentError('Depth must be a positive integer.');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function collectOption(value, previous) {
|
||||
return previous.concat(value);
|
||||
}
|
||||
|
||||
function validateAssetOptions(options, { requireAsset = false } = {}) {
|
||||
if (requireAsset && options.asset.length === 0) {
|
||||
throw new Error('At least one --asset is required.');
|
||||
}
|
||||
if (options.assetName && options.asset.length !== 1) {
|
||||
throw new Error('--asset-name requires exactly one --asset.');
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAssets(client, owner, repo, releaseId, assetPaths, assetName) {
|
||||
const uploaded = [];
|
||||
const failed = [];
|
||||
for (const assetPath of assetPaths) {
|
||||
const name = assetName || path.basename(assetPath);
|
||||
try {
|
||||
await client.uploadReleaseAsset(owner, repo, releaseId, assetPath, name);
|
||||
uploaded.push(name);
|
||||
console.log(`Asset uploaded: ${name}`);
|
||||
} catch (err) {
|
||||
failed.push({ name, error: err });
|
||||
console.error(`Asset failed: ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return { uploaded, failed };
|
||||
}
|
||||
|
||||
// Read commands share a --json flag that prints the raw API response
|
||||
// (pretty-printed) instead of the human-readable format.
|
||||
function printJson(data) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function makeTokenName() {
|
||||
const host = require('node:os').hostname() || 'unknown';
|
||||
return `stoke-${host}-${Date.now()}`;
|
||||
}
|
||||
|
||||
// Least-privilege default: enough for the daily issue/PR/repository commands.
|
||||
// Org administration (org create/avatar, team create/member-*) and anything
|
||||
// else outside this set needs --full-scopes or an explicit --scopes list.
|
||||
const DEFAULT_TOKEN_SCOPES = [
|
||||
'read:issue', 'write:issue',
|
||||
'read:repository', 'write:repository',
|
||||
'read:user',
|
||||
'read:organization',
|
||||
];
|
||||
|
||||
// The previous behavior: full read/write on every non-admin scope.
|
||||
const FULL_TOKEN_SCOPES = [
|
||||
'read:activitypub', 'write:activitypub',
|
||||
'read:issue', 'write:issue',
|
||||
'read:misc', 'write:misc',
|
||||
|
|
@ -99,6 +156,10 @@ const DEFAULT_TOKEN_SCOPES = [
|
|||
'read:user', 'write:user',
|
||||
];
|
||||
|
||||
function parseScopesOption(csv) {
|
||||
return csv.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function printErrorAndExit(err) {
|
||||
console.error(`Authentication failed: ${err.message}`);
|
||||
if (err.status) {
|
||||
|
|
@ -124,10 +185,27 @@ auth
|
|||
.option('-t, --token <token>', 'use an existing personal access token instead of generating one')
|
||||
.option('--token-file <path>', 'read an existing personal access token from a file')
|
||||
.option('--token-name <name>', 'name for the generated personal access token', makeTokenName())
|
||||
.option('--full-scopes', 'grant full read/write access on all non-admin scopes', false)
|
||||
.option('--scopes <csv>', 'comma-separated list of scopes for the generated token')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
let { url, username, password, passwordFile, token, tokenFile, tokenName } = options;
|
||||
|
||||
if (options.fullScopes && options.scopes) {
|
||||
console.error('Use either --full-scopes or --scopes, not both.');
|
||||
process.exit(1);
|
||||
}
|
||||
let scopes = DEFAULT_TOKEN_SCOPES;
|
||||
if (options.fullScopes) {
|
||||
scopes = FULL_TOKEN_SCOPES;
|
||||
} else if (options.scopes) {
|
||||
scopes = parseScopesOption(options.scopes);
|
||||
if (!scopes.length) {
|
||||
console.error('--scopes produced an empty scope list.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenFile) token = readSecretFile(tokenFile, 'token');
|
||||
if (passwordFile) password = readSecretFile(passwordFile, 'password');
|
||||
|
||||
|
|
@ -159,7 +237,7 @@ auth
|
|||
const me = await client.verifyBasicAuth(username, password);
|
||||
const login = me.login;
|
||||
|
||||
const tokenRes = await client.createToken(login, password, tokenName, DEFAULT_TOKEN_SCOPES);
|
||||
const tokenRes = await client.createToken(login, password, tokenName, scopes);
|
||||
if (!tokenRes.sha1) {
|
||||
throw new Error('Token generation succeeded but no token value was returned.');
|
||||
}
|
||||
|
|
@ -173,6 +251,7 @@ auth
|
|||
tokenId: tokenRes.id,
|
||||
};
|
||||
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
|
||||
console.log(`Scopes: ${scopes.join(', ')}`);
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
|
|
@ -216,6 +295,8 @@ auth
|
|||
} else {
|
||||
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
||||
}
|
||||
} else if (!config.tokenId && !options.localOnly) {
|
||||
console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
||||
}
|
||||
|
||||
clearConfig();
|
||||
|
|
@ -229,16 +310,25 @@ auth
|
|||
auth
|
||||
.command('status')
|
||||
.description('Show the current authentication status')
|
||||
.action(async () => {
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.token) {
|
||||
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);
|
||||
const me = await client.get('/user');
|
||||
if (options.json) {
|
||||
printJson(me);
|
||||
return;
|
||||
}
|
||||
console.log('Instance: ', config.url);
|
||||
console.log('Login: ', me.login);
|
||||
console.log('Username: ', me.username);
|
||||
|
|
@ -279,11 +369,16 @@ repo
|
|||
.command('list')
|
||||
.description('List repositories for the authenticated user')
|
||||
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const repos = await client.listRepos();
|
||||
if (options.json) {
|
||||
printJson(repos);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||
if (!display.length) {
|
||||
console.log('No repositories found.');
|
||||
|
|
@ -302,10 +397,115 @@ repo
|
|||
}
|
||||
});
|
||||
|
||||
// Hand the stored token to git through environment-based config instead of
|
||||
// the remote URL or `git clone -c`: GIT_CONFIG_* variables only live for the
|
||||
// duration of this process, so the token never reaches the command line,
|
||||
// the remote URL, or the cloned repository's .git/config (which `-c` would
|
||||
// write into). GIT_TERMINAL_PROMPT=0 keeps git from interactively asking
|
||||
// for credentials the session already owns.
|
||||
function gitAuthEnv(config) {
|
||||
const username = config.username || config.login || 'stoke';
|
||||
const basic = Buffer.from(`${username}:${config.token}`).toString('base64');
|
||||
return {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: `http.${config.url}.extraHeader`,
|
||||
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
|
||||
};
|
||||
}
|
||||
|
||||
repo
|
||||
.command('clone')
|
||||
.description('Clone a repository using the stored Forgejo credentials')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.argument('[directory]', 'destination directory (defaults to the repository name)')
|
||||
.option('--branch <branch>', 'checkout this branch instead of the default branch')
|
||||
.option('--depth <depth>', 'create a shallow clone with the given history depth', parseDepth)
|
||||
.option('--origin <name>', 'name for the created remote', 'origin')
|
||||
.action((directory, options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.url || !config.token) {
|
||||
throw new Error('Not authenticated. Run: stoke auth login');
|
||||
}
|
||||
const base = config.url.replace(/\/+$/, '');
|
||||
const cloneUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`;
|
||||
|
||||
const args = ['clone', '--origin', options.origin];
|
||||
if (options.branch) args.push('--branch', options.branch);
|
||||
if (options.depth) args.push('--depth', String(options.depth));
|
||||
args.push(cloneUrl);
|
||||
if (directory) args.push(directory);
|
||||
|
||||
const res = spawnSync('git', args, {
|
||||
stdio: 'inherit',
|
||||
env: gitAuthEnv({ ...config, url: base }),
|
||||
});
|
||||
if (res.error) {
|
||||
throw new Error(`Failed to run git: ${res.error.message}`);
|
||||
}
|
||||
if (res.status !== 0) {
|
||||
// Git's error output already went to stderr; forward its exit status
|
||||
// so scripts see the same failure a plain `git clone` would produce.
|
||||
process.exit(res.status == null ? 1 : res.status);
|
||||
}
|
||||
console.log(`Cloned ${options.owner}/${options.repo} into ${directory || options.repo}.`);
|
||||
} catch (err) {
|
||||
console.error(`Repository clone failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
repo
|
||||
.command('sync')
|
||||
.description('Fast-forward an imported repository from its upstream')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--from <upstream-url>', 'upstream Git URL')
|
||||
.option('--branch <branch>', 'branch to synchronize')
|
||||
.option('--tags', 'also create new upstream tags', false)
|
||||
.option('--dry-run', 'report changes without pushing', false)
|
||||
.action((options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.url || !config.token) {
|
||||
throw new Error('Not authenticated. Run: stoke auth login');
|
||||
}
|
||||
const base = config.url.replace(/\/+$/, '');
|
||||
const forgeUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`;
|
||||
const result = syncRepository({
|
||||
forgeUrl,
|
||||
upstreamUrl: options.from,
|
||||
branch: options.branch,
|
||||
includeTags: options.tags,
|
||||
dryRun: options.dryRun,
|
||||
env: gitAuthEnv({ ...config, url: base }),
|
||||
});
|
||||
if (result.changed) {
|
||||
console.log(`${result.branch} ${result.oldSha}..${result.newSha}`);
|
||||
} else {
|
||||
console.log(`${result.branch} is up to date at ${result.newSha}`);
|
||||
}
|
||||
for (const tag of result.newTags) {
|
||||
console.log(`tag ${tag.name} create ${tag.sha}`);
|
||||
}
|
||||
for (const tag of result.movedTags) {
|
||||
console.error(`tag ${tag.name} moved upstream: forge ${tag.forgeSha}, upstream ${tag.upstreamSha}; skipped`);
|
||||
}
|
||||
if (result.movedTags.length > 0) process.exitCode = 1;
|
||||
} catch (err) {
|
||||
console.error(`Repository sync failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
repo
|
||||
.command('create')
|
||||
.description('Create a new repository for the authenticated user')
|
||||
.description('Create a new repository for the authenticated user or an organization')
|
||||
.requiredOption('--name <name>', 'repository name')
|
||||
.option('-o, --owner <owner>', 'repository owner (authenticated user or organization)')
|
||||
.option('-d, --description <description>', 'repository description', '')
|
||||
.option('--private', 'make the repository private', false)
|
||||
.option('--public', 'make the repository public')
|
||||
|
|
@ -326,7 +526,7 @@ repo
|
|||
default_branch: options.defaultBranch,
|
||||
};
|
||||
|
||||
const result = await client.createRepo(payload);
|
||||
const result = await client.createRepo(payload, options.owner);
|
||||
console.log(`Repository created: ${result.full_name}`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
console.log(`Clone (SSH): ${result.ssh_url}`);
|
||||
|
|
@ -455,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 });
|
||||
|
|
@ -529,6 +729,7 @@ issue
|
|||
.option('-s, --state <state>', 'issue state: open, closed, all', 'open')
|
||||
.option('-t, --type <type>', 'issue type filter: issues, pulls', 'issues')
|
||||
.option('-l, --limit <number>', 'maximum issues to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -537,6 +738,10 @@ issue
|
|||
state: options.state,
|
||||
type: options.type,
|
||||
});
|
||||
if (options.json) {
|
||||
printJson(issues);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? issues.slice(0, options.limit) : issues;
|
||||
if (!display.length) {
|
||||
console.log('No issues found.');
|
||||
|
|
@ -563,6 +768,7 @@ issue
|
|||
.option('-b, --body <body>', 'issue body (markdown)')
|
||||
.option('--body-file <path>', 'read the issue body from a file')
|
||||
.option('--assignee <username...>', 'assign the issue to one or more users')
|
||||
.option('--label <name...>', 'apply one or more labels by name')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -574,6 +780,9 @@ issue
|
|||
if (options.assignee && options.assignee.length) {
|
||||
payload.assignees = options.assignee;
|
||||
}
|
||||
if (options.label && options.label.length) {
|
||||
payload.labels = await resolveLabelIds(client, options.owner, options.repo, options.label);
|
||||
}
|
||||
const result = await client.createIssue(options.owner, options.repo, payload);
|
||||
console.log(`Issue created: #${result.number} ${result.title}`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
|
|
@ -584,6 +793,64 @@ issue
|
|||
}
|
||||
});
|
||||
|
||||
issue
|
||||
.command('show')
|
||||
.description('Show details of an issue')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-n, --number <number>', 'issue number', parseId)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const issueData = await client.getIssue(options.owner, options.repo, options.number);
|
||||
if (options.json) {
|
||||
printJson(issueData);
|
||||
return;
|
||||
}
|
||||
const author = issueData.user?.login || '(unknown)';
|
||||
console.log(`#${issueData.number} [${issueData.state}] ${issueData.title}`);
|
||||
console.log(`URL: ${issueData.html_url}`);
|
||||
console.log(`Author: ${author}`);
|
||||
console.log(`Created: ${issueData.created_at}`);
|
||||
if (issueData.body) {
|
||||
console.log('\n' + issueData.body);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to show issue: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
issue
|
||||
.command('comment')
|
||||
.description('Add a comment to an issue')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-n, --number <number>', 'issue number', parseId)
|
||||
.option('-b, --body <body>', 'comment body (markdown; required unless --body-file)')
|
||||
.option('--body-file <path>', 'read the comment body from a file (wins over -b)')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const rawBody = readBodyOption(options) || '';
|
||||
if (rawBody.trim().length === 0) {
|
||||
console.error('Comment body is required. Use -b/--body or --body-file.');
|
||||
process.exit(1);
|
||||
}
|
||||
const result = await client.createIssueComment(options.owner, options.repo, options.number, rawBody);
|
||||
console.log(`Comment added to #${options.number}.`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to comment on issue: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const pr = program
|
||||
.command('pr')
|
||||
.description('Manage pull requests');
|
||||
|
|
@ -595,6 +862,7 @@ pr
|
|||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.option('-s, --state <state>', 'PR state: open, closed, all', 'open')
|
||||
.option('-l, --limit <number>', 'maximum pull requests to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -602,6 +870,10 @@ pr
|
|||
const pulls = await client.listPullRequests(options.owner, options.repo, {
|
||||
state: options.state,
|
||||
});
|
||||
if (options.json) {
|
||||
printJson(pulls);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls;
|
||||
if (!display.length) {
|
||||
console.log('No pull requests found.');
|
||||
|
|
@ -699,11 +971,16 @@ pr
|
|||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-n, --number <number>', 'pull request number', parseId)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const prData = await client.getPullRequest(options.owner, options.repo, options.number);
|
||||
if (options.json) {
|
||||
printJson(prData);
|
||||
return;
|
||||
}
|
||||
const author = prData.user?.login || '(unknown)';
|
||||
const headRef = prData.head?.ref || '?';
|
||||
const baseRef = prData.base?.ref || '?';
|
||||
|
|
@ -760,6 +1037,7 @@ pr
|
|||
.requiredOption('--event <event>', 'review event: approve|approved, request-changes|request_changes, comment')
|
||||
.option('-b, --body <body>', 'review body (markdown; required for request-changes and comment)')
|
||||
.option('--body-file <path>', 'read the review body from a file (wins over -b)')
|
||||
.option('--commit <sha>', 'commit SHA the review applies to (sent as commit_id)')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -774,7 +1052,7 @@ pr
|
|||
console.error(`Review event ${options.event} requires a non-empty body. Use -b/--body or --body-file.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody);
|
||||
const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody, { commitId: options.commit });
|
||||
console.log(`Review submitted on !${options.number}: ${event}.`);
|
||||
if (result && result.html_url) {
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
|
|
@ -786,6 +1064,327 @@ pr
|
|||
}
|
||||
});
|
||||
|
||||
const release = program
|
||||
.command('release')
|
||||
.description('Manage releases');
|
||||
|
||||
release
|
||||
.command('list')
|
||||
.description('List releases in a repository')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.option('-l, --limit <number>', 'maximum releases to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const releases = await client.listReleases(options.owner, options.repo);
|
||||
if (options.json) {
|
||||
printJson(releases);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? releases.slice(0, options.limit) : releases;
|
||||
if (!display.length) {
|
||||
console.log('No releases found.');
|
||||
return;
|
||||
}
|
||||
for (const rel of display) {
|
||||
const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|');
|
||||
const tag = flags ? `${rel.tag_name} [${flags}]` : rel.tag_name;
|
||||
console.log(`${tag} ${rel.name || ''}`.trimEnd());
|
||||
}
|
||||
if (releases.length > display.length) {
|
||||
console.log(`...and ${releases.length - display.length} more (use -l 0 for all).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to list releases: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
release
|
||||
.command('view')
|
||||
.description('Show the release for a tag')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--tag <tag>', 'tag name of the release')
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const rel = await client.getReleaseByTag(options.owner, options.repo, options.tag);
|
||||
if (options.json) {
|
||||
printJson(rel);
|
||||
return;
|
||||
}
|
||||
const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|');
|
||||
console.log(`${rel.tag_name}${flags ? ` [${flags}]` : ''} ${rel.name || ''}`.trimEnd());
|
||||
console.log(`URL: ${rel.html_url}`);
|
||||
console.log(`Target: ${rel.target_commitish}`);
|
||||
console.log(`Author: ${rel.author?.login || '(unknown)'}`);
|
||||
console.log(`Published: ${rel.published_at}`);
|
||||
if (rel.assets?.length) {
|
||||
console.log('\nAssets:');
|
||||
for (const asset of rel.assets) {
|
||||
console.log(` ${asset.name} (${asset.size} bytes) ${asset.browser_download_url}`);
|
||||
}
|
||||
}
|
||||
if (rel.body) {
|
||||
console.log('\n' + rel.body);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to show release: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
release
|
||||
.command('create')
|
||||
.description('Create a release (creates the tag too if it does not exist)')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--tag <tag>', 'tag name for the release')
|
||||
.option('--target <ref>', 'branch or commit the tag is created from (default: repository default branch)')
|
||||
.option('-t, --title <title>', 'release title (default: the tag name)')
|
||||
.option('-b, --body <body>', 'release notes (markdown)')
|
||||
.option('--body-file <path>', 'read the release notes from a file (wins over -b)')
|
||||
.option('--asset <path>', 'attach an asset (repeatable)', collectOption, [])
|
||||
.option('--asset-name <name>', 'override the uploaded filename (exactly one asset)')
|
||||
.option('--draft', 'create as a draft release', false)
|
||||
.option('--prerelease', 'mark as a prerelease', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
validateAssetOptions(options);
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const payload = {
|
||||
tag_name: options.tag,
|
||||
name: options.title || options.tag,
|
||||
body: readBodyOption(options) || '',
|
||||
draft: options.draft,
|
||||
prerelease: options.prerelease,
|
||||
};
|
||||
if (options.target) payload.target_commitish = options.target;
|
||||
const result = await client.createRelease(options.owner, options.repo, payload);
|
||||
console.log(`Release created: ${result.tag_name} ${result.name || ''}`.trimEnd());
|
||||
console.log(`Release id: ${result.id}`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
const uploads = await uploadAssets(
|
||||
client,
|
||||
options.owner,
|
||||
options.repo,
|
||||
result.id,
|
||||
options.asset,
|
||||
options.assetName,
|
||||
);
|
||||
if (uploads.failed.length) {
|
||||
console.error(
|
||||
`${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed. The release was kept.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Release creation failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
release
|
||||
.command('upload')
|
||||
.description('Attach assets to an existing release')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--tag <tag>', 'tag name of the release')
|
||||
.requiredOption('--asset <path>', 'asset to upload (repeatable)', collectOption, [])
|
||||
.option('--asset-name <name>', 'override the uploaded filename (exactly one asset)')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
validateAssetOptions(options, { requireAsset: true });
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const releaseResult = await client.getReleaseByTag(options.owner, options.repo, options.tag);
|
||||
const uploads = await uploadAssets(
|
||||
client,
|
||||
options.owner,
|
||||
options.repo,
|
||||
releaseResult.id,
|
||||
options.asset,
|
||||
options.assetName,
|
||||
);
|
||||
if (uploads.failed.length) {
|
||||
console.error(`${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Release upload failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
function parseColor(value) {
|
||||
const hex = value.replace(/^#/, '');
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
throw new InvalidArgumentError('Color must be 6 hex digits (with or without a leading #).');
|
||||
}
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
|
||||
// Label add/remove/lookup go through names on the CLI but ids on the wire,
|
||||
// so every caller resolves against the repo's label list first.
|
||||
async function resolveLabelIds(client, owner, repo, names) {
|
||||
const labels = await client.listLabels(owner, repo);
|
||||
const byName = new Map(labels.map((l) => [l.name, l.id]));
|
||||
return names.map((name) => {
|
||||
const id = byName.get(name);
|
||||
if (id === undefined) {
|
||||
throw new Error(`Label not found in ${owner}/${repo}: ${name}`);
|
||||
}
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
const label = program
|
||||
.command('label')
|
||||
.description('Manage repository labels');
|
||||
|
||||
label
|
||||
.command('list')
|
||||
.description('List labels in a repository')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.option('-l, --limit <number>', 'maximum labels to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const labels = await client.listLabels(options.owner, options.repo);
|
||||
if (options.json) {
|
||||
printJson(labels);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? labels.slice(0, options.limit) : labels;
|
||||
if (!display.length) {
|
||||
console.log('No labels found.');
|
||||
return;
|
||||
}
|
||||
for (const l of display) {
|
||||
console.log(`#${l.id} ${l.name} #${l.color}${l.description ? ` — ${l.description}` : ''}`);
|
||||
}
|
||||
if (labels.length > display.length) {
|
||||
console.log(`...and ${labels.length - display.length} more (use -l 0 for all).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to list labels: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
label
|
||||
.command('create')
|
||||
.description('Create a label in a repository')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--name <name>', 'label name')
|
||||
.requiredOption('--color <color>', 'label color, 6 hex digits (with or without #)', parseColor)
|
||||
.option('-d, --description <description>', 'label description', '')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const result = await client.createLabel(options.owner, options.repo, {
|
||||
name: options.name,
|
||||
color: options.color,
|
||||
description: options.description,
|
||||
});
|
||||
console.log(`Label created: #${result.id} ${result.name} #${result.color}`);
|
||||
} catch (err) {
|
||||
console.error(`Label creation failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
label
|
||||
.command('delete')
|
||||
.description('Delete a label from a repository (by --id or --name)')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.option('--id <id>', 'label id', parseId)
|
||||
.option('--name <name>', 'label name')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (!options.id && !options.name) {
|
||||
console.error('One of --id or --name is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (options.id && options.name) {
|
||||
console.error('Use either --id or --name, not both.');
|
||||
process.exit(1);
|
||||
}
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const ids = options.id
|
||||
? [options.id]
|
||||
: await resolveLabelIds(client, options.owner, options.repo, [options.name]);
|
||||
await client.deleteLabel(options.owner, options.repo, ids[0]);
|
||||
console.log(`Label deleted: ${options.name || `#${options.id}`} from ${options.owner}/${options.repo}.`);
|
||||
} catch (err) {
|
||||
console.error(`Label deletion failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
label
|
||||
.command('add')
|
||||
.description('Add labels to an issue or pull request')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-n, --number <number>', 'issue or pull request number', parseId)
|
||||
.requiredOption('--name <name...>', 'one or more label names')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const ids = await resolveLabelIds(client, options.owner, options.repo, options.name);
|
||||
await client.addIssueLabels(options.owner, options.repo, options.number, ids);
|
||||
console.log(`Labels added to #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to add labels: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
label
|
||||
.command('remove')
|
||||
.description('Remove labels from an issue or pull request')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-n, --number <number>', 'issue or pull request number', parseId)
|
||||
.requiredOption('--name <name...>', 'one or more label names')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const ids = await resolveLabelIds(client, options.owner, options.repo, options.name);
|
||||
for (const id of ids) {
|
||||
await client.removeIssueLabel(options.owner, options.repo, options.number, id);
|
||||
}
|
||||
console.log(`Labels removed from #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to remove labels: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const branchCmd = program
|
||||
.command('branch')
|
||||
.description('Manage branches');
|
||||
|
|
@ -796,11 +1395,16 @@ branchCmd
|
|||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.option('-l, --limit <number>', 'maximum branches to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const branches = await client.listBranches(options.owner, options.repo);
|
||||
if (options.json) {
|
||||
printJson(branches);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? branches.slice(0, options.limit) : branches;
|
||||
if (!display.length) {
|
||||
console.log('No branches found.');
|
||||
|
|
@ -883,11 +1487,16 @@ org
|
|||
.description('List repositories owned by an organization')
|
||||
.requiredOption('-o, --org <org>', 'organization name')
|
||||
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const repos = await client.listOrgRepos(options.org);
|
||||
if (options.json) {
|
||||
printJson(repos);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||
if (!display.length) {
|
||||
console.log('No repositories found.');
|
||||
|
|
@ -938,11 +1547,16 @@ team
|
|||
.command('list')
|
||||
.description('List teams in an organization')
|
||||
.requiredOption('-o, --org <org>', 'organization name')
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const teams = await client.listOrgTeams(options.org);
|
||||
if (options.json) {
|
||||
printJson(teams);
|
||||
return;
|
||||
}
|
||||
if (!teams.length) {
|
||||
console.log('No teams found.');
|
||||
return;
|
||||
|
|
@ -991,11 +1605,16 @@ team
|
|||
.command('member-list')
|
||||
.description('List members of a team')
|
||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const members = await client.listTeamMembers(options.teamId);
|
||||
if (options.json) {
|
||||
printJson(members);
|
||||
return;
|
||||
}
|
||||
if (!members.length) {
|
||||
console.log('No members found.');
|
||||
return;
|
||||
|
|
@ -1055,11 +1674,16 @@ user
|
|||
.description('Search/list users on the Forgejo instance')
|
||||
.option('-q, --query <query>', 'search query (empty lists all visible users)', '')
|
||||
.option('-l, --limit <number>', 'maximum users to return (0 for all)', parseLimit, 50)
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const users = await client.searchUsers(options.query);
|
||||
if (options.json) {
|
||||
printJson(users);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? users.slice(0, options.limit) : users;
|
||||
if (!display.length) {
|
||||
console.log('No users found.');
|
||||
|
|
@ -1082,11 +1706,16 @@ user
|
|||
.command('show')
|
||||
.description('Show a single user profile')
|
||||
.requiredOption('-u, --user <username>', 'username to look up')
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const u = await client.getUser(options.user);
|
||||
if (options.json) {
|
||||
printJson(u);
|
||||
return;
|
||||
}
|
||||
console.log(`Login: ${u.login}`);
|
||||
console.log(`Full name: ${u.full_name || '-'}`);
|
||||
console.log(`Email: ${u.email || '-'}`);
|
||||
|
|
@ -1098,6 +1727,65 @@ user
|
|||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('api')
|
||||
.description('Make an authenticated request to any Forgejo API endpoint and print the JSON response')
|
||||
.argument('<endpoint>', 'endpoint path starting with / (the /api/v1 prefix is added for you)')
|
||||
.option('-X, --method <method>', 'HTTP method (default: GET, or POST when --input is given)')
|
||||
.option('--input <json>', 'JSON request body, inline or @path to read it from a file')
|
||||
.option('--paginate', 'fetch all pages (GET endpoints returning a JSON array)', false)
|
||||
.action(async (endpoint, options) => {
|
||||
try {
|
||||
if (!endpoint.startsWith('/')) {
|
||||
console.error('Endpoint must start with / (e.g. /repos/owner/repo/pulls?state=closed).');
|
||||
process.exit(1);
|
||||
}
|
||||
const method = (options.method || (options.input ? 'POST' : 'GET')).toUpperCase();
|
||||
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
console.error(`Unsupported method: ${method}. Use GET, POST, PUT, PATCH or DELETE.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (options.paginate && method !== 'GET') {
|
||||
console.error('--paginate only works with GET.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (method === 'GET' && options.input !== undefined) {
|
||||
console.error('GET requests cannot carry a body. Drop --input, or use -X POST/PUT/PATCH/DELETE.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let body = null;
|
||||
if (options.input !== undefined) {
|
||||
const raw = options.input.startsWith('@')
|
||||
? (() => {
|
||||
try {
|
||||
return fs.readFileSync(options.input.slice(1), 'utf8');
|
||||
} catch (err) {
|
||||
throw new Error(`Could not read input file ${options.input.slice(1)}: ${err.message}`);
|
||||
}
|
||||
})()
|
||||
: options.input;
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`--input is not valid JSON: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const data = options.paginate
|
||||
? await client.getAll(endpoint)
|
||||
: await client.request(method, endpoint, body);
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.error(`API request failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
|
|
|||
177
src/repo-sync.js
Normal file
177
src/repo-sync.js
Normal 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 };
|
||||
185
test/api.test.js
185
test/api.test.js
|
|
@ -1,13 +1,18 @@
|
|||
const { test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ForgejoClient } = require('../src/api');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const realFetch = global.fetch;
|
||||
const realAbortTimeout = AbortSignal.timeout;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
AbortSignal.timeout = realAbortTimeout;
|
||||
});
|
||||
|
||||
function mockFetch(handler) {
|
||||
|
|
@ -66,6 +71,41 @@ test('deleteToken uses Basic auth (Forgejo rejects token auth on token endpoints
|
|||
assert.match(opts.headers.Authorization, /^Basic /);
|
||||
});
|
||||
|
||||
test('createRepo without an owner keeps the authenticated-user route', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ full_name: 'bot/project' }, 201));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.createRepo({ name: 'project' });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/user/repos');
|
||||
assert.equal(calls[0].opts.method, 'POST');
|
||||
});
|
||||
|
||||
test('createRepo treats a case-insensitive authenticated owner as the user route', async () => {
|
||||
const calls = mockFetch((url) => {
|
||||
if (url.endsWith('/user')) return jsonResponse({ login: 'BuildBot' });
|
||||
return jsonResponse({ full_name: 'BuildBot/project' }, 201);
|
||||
});
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.createRepo({ name: 'project' }, 'buildbot');
|
||||
assert.deepEqual(calls.map(({ url }) => url), [
|
||||
'https://forge.test/api/v1/user',
|
||||
'https://forge.test/api/v1/user/repos',
|
||||
]);
|
||||
});
|
||||
|
||||
test('createRepo routes a different owner to the organization endpoint', async () => {
|
||||
const calls = mockFetch((url) => {
|
||||
if (url.endsWith('/user')) return jsonResponse({ login: 'buildbot' });
|
||||
return jsonResponse({ full_name: 'heavy-duty/project' }, 201);
|
||||
});
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.createRepo({ name: 'project' }, 'heavy-duty');
|
||||
assert.deepEqual(calls.map(({ url }) => url), [
|
||||
'https://forge.test/api/v1/user',
|
||||
'https://forge.test/api/v1/orgs/heavy-duty/repos',
|
||||
]);
|
||||
});
|
||||
|
||||
test('API errors carry message, status and body', async () => {
|
||||
mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
|
|
@ -151,6 +191,24 @@ test('getPullRequest fetches a single pull request', async () => {
|
|||
assert.equal(calls[0].opts.method, 'GET');
|
||||
});
|
||||
|
||||
test('getIssue fetches a single issue', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ number: 7, title: 'Bug' }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
const issue = await client.getIssue('owner', 'repo', 7);
|
||||
assert.equal(issue.number, 7);
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7');
|
||||
assert.equal(calls[0].opts.method, 'GET');
|
||||
});
|
||||
|
||||
test('createIssueComment posts to the issue comments endpoint', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ id: 5, html_url: 'https://forge.test/comment/5' }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.createIssueComment('owner', 'repo', 7, 'Me too.');
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/comments');
|
||||
assert.equal(calls[0].opts.method, 'POST');
|
||||
assert.equal(JSON.parse(calls[0].opts.body).body, 'Me too.');
|
||||
});
|
||||
|
||||
test('createPullRequestComment posts to the issue comments endpoint', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
|
|
@ -179,3 +237,130 @@ test('createPullRequestReview preserves leading and trailing whitespace in the b
|
|||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.body, rawBody);
|
||||
});
|
||||
|
||||
test('createPullRequestReview omits commit_id unless a commitId is given', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ id: 90 }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '');
|
||||
assert.ok(!('commit_id' in JSON.parse(calls[0].opts.body)));
|
||||
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '', { commitId: 'abc123' });
|
||||
assert.equal(JSON.parse(calls[1].opts.body).commit_id, 'abc123');
|
||||
});
|
||||
|
||||
test('getAll joins pagination with & when the endpoint already has a query', async () => {
|
||||
const calls = mockFetch(() => jsonResponse([]));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.getAll('/repos/o/r/pulls?state=closed');
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/o/r/pulls?state=closed&limit=50&page=1');
|
||||
});
|
||||
|
||||
test('getAll overrides caller-supplied limit/page instead of duplicating them', async () => {
|
||||
const calls = mockFetch((url) => {
|
||||
const page = Number(new URL(url).searchParams.get('page'));
|
||||
return jsonResponse(page === 1 ? Array.from({ length: 50 }, (_, i) => ({ id: i })) : []);
|
||||
});
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
const all = await client.getAll('/repos/o/r/pulls?state=closed&limit=1&page=9');
|
||||
assert.equal(all.length, 50);
|
||||
const first = new URL(calls[0].url).searchParams;
|
||||
const second = new URL(calls[1].url).searchParams;
|
||||
assert.deepEqual(first.getAll('limit'), ['50']);
|
||||
assert.deepEqual(first.getAll('page'), ['1']);
|
||||
assert.deepEqual(second.getAll('page'), ['2']);
|
||||
assert.equal(first.get('state'), 'closed');
|
||||
});
|
||||
|
||||
test('release endpoints map to the expected URLs and payloads', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ tag_name: '1.0.0' }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.listReleases('owner', 'repo');
|
||||
await client.getReleaseByTag('owner', 'repo', '1.0.0-rc1');
|
||||
await client.createRelease('owner', 'repo', { tag_name: '1.0.0', name: '1.0.0', body: '', draft: false, prerelease: false });
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/releases?limit=50&page=1');
|
||||
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/releases/tags/1.0.0-rc1');
|
||||
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/releases');
|
||||
assert.equal(calls[2].opts.method, 'POST');
|
||||
assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0');
|
||||
});
|
||||
|
||||
test('uploadReleaseAsset streams multipart data without forcing a JSON content type', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-api-'));
|
||||
const assetPath = path.join(dir, 'artifact.bin');
|
||||
fs.writeFileSync(assetPath, 'asset bytes');
|
||||
const calls = mockFetch(() => jsonResponse({ id: 9, name: 'custom name.bin' }, 201));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
|
||||
try {
|
||||
await client.uploadReleaseAsset('heavy duty', 'stoke', 42, assetPath, 'custom name.bin');
|
||||
const { url, opts } = calls[0];
|
||||
assert.equal(url, 'https://forge.test/api/v1/repos/heavy%20duty/stoke/releases/42/assets?name=custom+name.bin');
|
||||
assert.equal(opts.method, 'POST');
|
||||
assert.equal(opts.headers.Authorization, 'token tok');
|
||||
assert.equal(opts.headers['Content-Type'], undefined);
|
||||
const attachment = opts.body.get('attachment');
|
||||
assert.equal(attachment.name, 'custom name.bin');
|
||||
assert.equal(await attachment.text(), 'asset bytes');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('uploadReleaseAsset identifies a missing local asset path', async () => {
|
||||
const assetPath = path.join(os.tmpdir(), `stoke-missing-asset-${process.pid}.bin`);
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
|
||||
await assert.rejects(
|
||||
client.uploadReleaseAsset('owner', 'repo', 42, assetPath, 'artifact.bin'),
|
||||
(err) => {
|
||||
assert.match(err.message, /Could not read asset file/);
|
||||
assert.match(err.message, new RegExp(assetPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('uploadReleaseAsset uses the upload timeout instead of the 30 second JSON timeout', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-timeout-'));
|
||||
const assetPath = path.join(dir, 'large.bin');
|
||||
fs.writeFileSync(assetPath, 'content');
|
||||
let timeout;
|
||||
AbortSignal.timeout = (milliseconds) => {
|
||||
timeout = milliseconds;
|
||||
return new AbortController().signal;
|
||||
};
|
||||
mockFetch(() => jsonResponse({ id: 10 }, 201));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
|
||||
try {
|
||||
await client.uploadReleaseAsset('owner', 'repo', 42, assetPath, 'large.bin');
|
||||
assert.equal(timeout, 10 * 60 * 1000);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('label endpoints map to the expected URLs and payloads', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ id: 3 }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.listLabels('owner', 'repo');
|
||||
await client.createLabel('owner', 'repo', { name: 'release', color: '0e8a16', description: '' });
|
||||
await client.deleteLabel('owner', 'repo', 3);
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/labels?limit=50&page=1');
|
||||
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/labels');
|
||||
assert.equal(calls[1].opts.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(calls[1].opts.body), { name: 'release', color: '0e8a16', description: '' });
|
||||
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/labels/3');
|
||||
assert.equal(calls[2].opts.method, 'DELETE');
|
||||
});
|
||||
|
||||
test('issue label add/remove hit the issue labels endpoints', async () => {
|
||||
const calls = mockFetch(() => jsonResponse(null, 204));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.addIssueLabels('owner', 'repo', 7, [3, 4]);
|
||||
await client.removeIssueLabel('owner', 'repo', 7, 3);
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels');
|
||||
assert.equal(calls[0].opts.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), { labels: [3, 4] });
|
||||
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels/3');
|
||||
assert.equal(calls[1].opts.method, 'DELETE');
|
||||
});
|
||||
|
|
|
|||
65
test/build-deb.test.js
Normal file
65
test/build-deb.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
76
test/changelog-section.test.js
Normal file
76
test/changelog-section.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
849
test/cli.test.js
849
test/cli.test.js
|
|
@ -23,7 +23,7 @@ test('--version matches package.json', () => {
|
|||
|
||||
test('--help lists every top-level command', () => {
|
||||
const out = execFileSync(process.execPath, [CLI, '--help'], { encoding: 'utf8' });
|
||||
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'branch', 'collaborator', 'org', 'user']) {
|
||||
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'release', 'label', 'branch', 'collaborator', 'org', 'user', 'api']) {
|
||||
assert.match(out, new RegExp(`^\\s+${cmd}`, 'm'), `missing command: ${cmd}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -40,10 +40,59 @@ test('global --config flag overrides the config location', () => {
|
|||
// "Not authenticated" instead of silently using the default config.
|
||||
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
|
||||
const res = run(['--config', missing, 'auth', 'status']);
|
||||
assert.equal(res.status, 0);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stdout, /Not authenticated/);
|
||||
});
|
||||
|
||||
test('auth status reports an absent session in text and JSON with a failing status', () => {
|
||||
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}-auth-status.json`);
|
||||
|
||||
const text = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing });
|
||||
assert.equal(text.status, 1);
|
||||
assert.equal(text.stdout, 'Not authenticated.\n');
|
||||
assert.equal(text.stderr, '');
|
||||
|
||||
const json = run(['auth', 'status', '--json'], { STOKE_CONFIG_FILE: missing });
|
||||
assert.equal(json.status, 1);
|
||||
assert.equal(json.stdout, '{"authenticated": false}\n');
|
||||
assert.equal(json.stderr, '');
|
||||
});
|
||||
|
||||
test('auth logout identifies a supplied token that remains active without changing local-only output', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-auth-logout-'));
|
||||
const cfg = path.join(dir, 'config.json');
|
||||
const config = {
|
||||
url: 'https://forge.test',
|
||||
login: 'bot',
|
||||
username: 'bot',
|
||||
token: 'token-that-must-not-be-printed',
|
||||
tokenId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
fs.writeFileSync(cfg, JSON.stringify(config));
|
||||
const logout = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(logout.status, 0, logout.stderr);
|
||||
assert.match(logout.stdout, /local credentials/i);
|
||||
assert.match(logout.stdout, /did not create this token/i);
|
||||
assert.match(logout.stdout, /cannot revoke it/i);
|
||||
assert.match(logout.stdout, /still valid on https:\/\/forge\.test/i);
|
||||
assert.match(logout.stdout, /Settings > Applications/);
|
||||
assert.doesNotMatch(logout.stdout, /Revoked token/);
|
||||
assert.doesNotMatch(logout.stdout, /Password for/);
|
||||
assert.doesNotMatch(logout.stdout, /token-that-must-not-be-printed/);
|
||||
assert.equal(fs.existsSync(cfg), false);
|
||||
|
||||
fs.writeFileSync(cfg, JSON.stringify(config));
|
||||
const localOnly = run(['auth', 'logout', '--local-only'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(localOnly.status, 0, localOnly.stderr);
|
||||
assert.equal(localOnly.stdout, 'Local credentials removed.\n');
|
||||
assert.equal(fs.existsSync(cfg), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid --limit is rejected before any network call', () => {
|
||||
const res = run(['repo', 'list', '-l', 'abc']);
|
||||
assert.equal(res.status, 1);
|
||||
|
|
@ -56,6 +105,307 @@ test('invalid --team-id is rejected before any network call', () => {
|
|||
assert.match(res.stderr, /Id must be a positive integer/);
|
||||
});
|
||||
|
||||
test('repo create help lists the owner option', () => {
|
||||
const res = run(['repo', 'create', '--help']);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(res.stdout, /-o, --owner <owner>/);
|
||||
});
|
||||
|
||||
test('release asset commands expose repeatable assets and a single-asset name override', () => {
|
||||
const create = run(['release', 'create', '--help']);
|
||||
assert.equal(create.status, 0, create.stderr);
|
||||
assert.match(create.stdout, /--asset <path>/);
|
||||
assert.match(create.stdout, /--asset-name <name>/);
|
||||
|
||||
const upload = run(['release', 'upload', '--help']);
|
||||
assert.equal(upload.status, 0, upload.stderr);
|
||||
assert.match(upload.stdout, /--tag <tag>/);
|
||||
assert.match(upload.stdout, /--asset <path>/);
|
||||
assert.match(upload.stdout, /--asset-name <name>/);
|
||||
});
|
||||
|
||||
test('release create rejects one asset name for multiple assets before reading config', () => {
|
||||
const res = run([
|
||||
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
|
||||
'--asset', 'one.bin', '--asset', 'two.bin', '--asset-name', 'named.bin',
|
||||
], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /--asset-name requires exactly one --asset/);
|
||||
assert.doesNotMatch(res.stderr, /Not authenticated/);
|
||||
});
|
||||
|
||||
test('release upload rejects zero assets before reading config', () => {
|
||||
const res = run([
|
||||
'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1',
|
||||
], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /at least one --asset is required/i);
|
||||
assert.doesNotMatch(res.stderr, /Not authenticated/);
|
||||
});
|
||||
|
||||
test('release create prints the id and uploads every asset as multipart data', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-create-'));
|
||||
const cfg = path.join(dir, 'config.json');
|
||||
const first = path.join(dir, 'first.bin');
|
||||
const second = path.join(dir, 'second.bin');
|
||||
fs.writeFileSync(first, 'first payload');
|
||||
fs.writeFileSync(second, 'second payload');
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
requests.push({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
contentType: req.headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (req.url === '/api/v1/repos/o/r/releases') {
|
||||
res.writeHead(201);
|
||||
res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'Version 1', html_url: 'https://forge.test/o/r/releases/v1' }));
|
||||
} else {
|
||||
res.writeHead(201);
|
||||
res.end(JSON.stringify({ id: requests.length, name: new URL(req.url, 'http://local').searchParams.get('name') }));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const result = await spawnAsync([
|
||||
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
|
||||
'--asset', first, '--asset', second,
|
||||
], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Release id: 42/i);
|
||||
assert.match(result.stdout, /Asset uploaded: first\.bin/);
|
||||
assert.match(result.stdout, /Asset uploaded: second\.bin/);
|
||||
assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
|
||||
{ method: 'POST', url: '/api/v1/repos/o/r/releases' },
|
||||
{ method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=first.bin' },
|
||||
{ method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=second.bin' },
|
||||
]);
|
||||
assert.match(requests[1].contentType, /^multipart\/form-data; boundary=/);
|
||||
assert.match(requests[1].body, /first payload/);
|
||||
assert.match(requests[2].body, /second payload/);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('release upload resolves the tag once and applies a single asset name override', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-upload-'));
|
||||
const cfg = path.join(dir, 'config.json');
|
||||
const asset = path.join(dir, 'original.bin');
|
||||
fs.writeFileSync(asset, 'upload payload');
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
requests.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString('utf8') });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (req.method === 'GET') {
|
||||
res.end(JSON.stringify({ id: 7, tag_name: 'v1' }));
|
||||
} else {
|
||||
res.writeHead(201);
|
||||
res.end(JSON.stringify({ id: 8, name: 'renamed.bin' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const result = await spawnAsync([
|
||||
'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1',
|
||||
'--asset', asset, '--asset-name', 'renamed.bin',
|
||||
], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Asset uploaded: renamed\.bin/);
|
||||
assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
|
||||
{ method: 'GET', url: '/api/v1/repos/o/r/releases/tags/v1' },
|
||||
{ method: 'POST', url: '/api/v1/repos/o/r/releases/7/assets?name=renamed.bin' },
|
||||
]);
|
||||
assert.match(requests[1].body, /upload payload/);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('release upload streams a large asset through receiver backpressure', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-large-upload-'));
|
||||
const cfg = path.join(dir, 'config.json');
|
||||
const asset = path.join(dir, 'large.bin');
|
||||
const assetSize = 8 * 1024 * 1024;
|
||||
fs.writeFileSync(asset, Buffer.alloc(assetSize, 0x61));
|
||||
let uploadedBytes = 0;
|
||||
let paused = false;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 7, tag_name: 'v1' }));
|
||||
return;
|
||||
}
|
||||
req.on('data', (chunk) => {
|
||||
uploadedBytes += chunk.length;
|
||||
if (!paused) {
|
||||
paused = true;
|
||||
req.pause();
|
||||
setTimeout(() => req.resume(), 100);
|
||||
}
|
||||
});
|
||||
req.on('end', () => {
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 8, name: 'large.bin' }));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const result = await spawnAsync(
|
||||
['release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1', '--asset', asset],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(paused, true);
|
||||
assert.ok(uploadedBytes > assetSize, `multipart body ${uploadedBytes} did not include ${assetSize} asset bytes`);
|
||||
assert.match(result.stdout, /Asset uploaded: large\.bin/);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('release create keeps the release and reports landed and failed assets', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-partial-'));
|
||||
const cfg = path.join(dir, 'config.json');
|
||||
const good = path.join(dir, 'good.bin');
|
||||
const bad = path.join(dir, 'bad.bin');
|
||||
fs.writeFileSync(good, 'good');
|
||||
fs.writeFileSync(bad, 'bad');
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
requests.push({ method: req.method, url: req.url });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (req.url === '/api/v1/repos/o/r/releases') {
|
||||
res.writeHead(201);
|
||||
res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'v1', html_url: 'https://forge.test/release/v1' }));
|
||||
} else if (req.url.includes('good.bin')) {
|
||||
res.writeHead(201);
|
||||
res.end(JSON.stringify({ id: 1, name: 'good.bin' }));
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ message: 'storage unavailable' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const result = await spawnAsync([
|
||||
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
|
||||
'--asset', bad, '--asset', good,
|
||||
], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /Release id: 42/i);
|
||||
assert.match(result.stdout, /Asset uploaded: good\.bin/);
|
||||
assert.match(result.stderr, /Asset failed: bad\.bin: storage unavailable/);
|
||||
assert.match(result.stderr, /release was kept/i);
|
||||
assert.deepEqual(requests.map(({ url }) => url), [
|
||||
'/api/v1/repos/o/r/releases',
|
||||
'/api/v1/repos/o/r/releases/42/assets?name=bad.bin',
|
||||
'/api/v1/repos/o/r/releases/42/assets?name=good.bin',
|
||||
]);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('release view lists attached assets with their sizes and download URLs', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-view-assets-'));
|
||||
const cfg = path.join(dir, 'config.json');
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
id: 42,
|
||||
tag_name: 'v1',
|
||||
name: 'Version 1',
|
||||
html_url: 'https://forge.test/o/r/releases/v1',
|
||||
target_commitish: 'main',
|
||||
author: { login: 'bot' },
|
||||
published_at: '2026-08-30T00:00:00Z',
|
||||
body: '',
|
||||
assets: [
|
||||
{ name: 'first.bin', size: 12, browser_download_url: 'https://forge.test/assets/first.bin' },
|
||||
{ name: 'second.bin', size: 2048, browser_download_url: 'https://forge.test/assets/second.bin' },
|
||||
],
|
||||
}));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const result = await spawnAsync(
|
||||
['release', 'view', '-o', 'o', '-r', 'r', '--tag', 'v1'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Assets:/);
|
||||
assert.match(result.stdout, /first\.bin \(12 bytes\) https:\/\/forge\.test\/assets\/first\.bin/);
|
||||
assert.match(result.stdout, /second\.bin \(2048 bytes\) https:\/\/forge\.test\/assets\/second\.bin/);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo create surfaces an organization permission failure and HTTP status', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-repo-create-403.json`);
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
requests.push({ method: req.method, url: req.url });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (req.url === '/api/v1/user') {
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ login: 'buildbot' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(403);
|
||||
res.end(JSON.stringify({ message: 'user does not have permission to create repositories' }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['repo', 'create', '--owner', 'heavy-duty', '--name', 'project'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /user does not have permission to create repositories/);
|
||||
assert.match(res.stderr, /HTTP status: 403/);
|
||||
assert.deepEqual(requests, [
|
||||
{ method: 'GET', url: '/api/v1/user' },
|
||||
{ method: 'POST', url: '/api/v1/orgs/heavy-duty/repos' },
|
||||
]);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('pr merge validates --number before any network call', () => {
|
||||
const res = run(['pr', 'merge', '-o', 'o', '-r', 'r', '-n', 'seven']);
|
||||
assert.equal(res.status, 1);
|
||||
|
|
@ -77,6 +427,75 @@ test('issue create --body-file reports unreadable files cleanly', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('issue create resolves repeated label names into the initial create payload', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-issue-labels.json`);
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let data = '';
|
||||
req.on('data', (chunk) => { data += chunk; });
|
||||
req.on('end', () => {
|
||||
requests.push({ method: req.method, url: req.url, body: data });
|
||||
res.writeHead(req.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' });
|
||||
if (req.method === 'GET') {
|
||||
res.end(JSON.stringify([
|
||||
{ id: 107, name: 'ready', color: '0e8a16' },
|
||||
{ id: 100, name: 'enhancement', color: '84b6eb' },
|
||||
]));
|
||||
} else {
|
||||
res.end(JSON.stringify({ number: 27, title: 'Probe', html_url: 'https://forge.test/o/r/issues/27' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['issue', 'create', '-o', 'o', '-r', 'r', '-t', 'Probe', '--label', 'ready', '--label', 'enhancement'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(requests.map(({ method }) => method), ['GET', 'POST']);
|
||||
assert.match(requests[0].url, /^\/api\/v1\/repos\/o\/r\/labels\?/);
|
||||
assert.equal(requests[1].url, '/api/v1/repos/o/r/issues');
|
||||
assert.deepEqual(JSON.parse(requests[1].body), {
|
||||
title: 'Probe',
|
||||
body: '',
|
||||
labels: [107, 100],
|
||||
});
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue create rejects an unknown label before creating the issue', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-unknown-issue-label.json`);
|
||||
let createRequests = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'POST') createRequests += 1;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify([{ id: 107, name: 'ready', color: '0e8a16' }]));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['issue', 'create', '-o', 'o', '-r', 'r', '-t', 'Probe', '--label', 'nonexistent'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Label not found in o\/r: nonexistent/);
|
||||
assert.equal(createRequests, 0);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('pr show validates --number before any network call', () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||
|
|
@ -176,6 +595,132 @@ test('pr review approve allows an empty body before any network call', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('label create rejects an invalid color before any network call', () => {
|
||||
const res = run(['label', 'create', '-o', 'o', '-r', 'r', '--name', 'x', '--color', 'red']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Color must be 6 hex digits/);
|
||||
});
|
||||
|
||||
test('label delete requires one of --id or --name before any network call', () => {
|
||||
const res = run(['label', 'delete', '-o', 'o', '-r', 'r']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /One of --id or --name is required/);
|
||||
});
|
||||
|
||||
test('api rejects an endpoint without a leading slash before any network call', () => {
|
||||
const res = run(['api', 'repos/o/r']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Endpoint must start with \//);
|
||||
});
|
||||
|
||||
test('api rejects an unsupported method before any network call', () => {
|
||||
const res = run(['api', '/user', '-X', 'HEAD']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Unsupported method/);
|
||||
});
|
||||
|
||||
test('api rejects --paginate with a non-GET method before any network call', () => {
|
||||
const res = run(['api', '/user', '-X', 'POST', '--paginate']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /--paginate only works with GET/);
|
||||
});
|
||||
|
||||
test('api rejects GET with --input before any network call', () => {
|
||||
const res = run(['api', '/user', '-X', 'GET', '--input', '{}']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /GET requests cannot carry a body/);
|
||||
});
|
||||
|
||||
test('label delete rejects --id and --name together before any network call', () => {
|
||||
const res = run(['label', 'delete', '-o', 'o', '-r', 'r', '--id', '3', '--name', 'x']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /either --id or --name, not both/);
|
||||
});
|
||||
|
||||
test('api rejects invalid JSON input before any network call', () => {
|
||||
const res = run(['api', '/user', '--input', '{nope']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /not valid JSON/);
|
||||
});
|
||||
|
||||
test('api sends the token and prints the JSON response', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-api.json`);
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
let authHeader = null;
|
||||
const server = http.createServer((req, res) => {
|
||||
authHeader = req.headers.authorization;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ login: 'bot' }));
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const res = await spawnAsync(['api', '/user'], { STOKE_CONFIG_FILE: cfg });
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve({ res, authHeader }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
assert.equal(result.res.status, 0, result.res.stderr);
|
||||
assert.equal(result.authHeader, 'token tok');
|
||||
assert.deepEqual(JSON.parse(result.res.stdout), { login: 'bot' });
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('label add fails closed on an unknown label name', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-lbl.json`);
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify([{ id: 1, name: 'bug', color: 'd73a4a' }]));
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['label', 'add', '-o', 'o', '-r', 'r', '-n', '7', '--name', 'ghost'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve(res));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Label not found in o\/r: ghost/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
function spawnAsync(args, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [CLI, ...args], {
|
||||
|
|
@ -295,4 +840,302 @@ test('pr review prints the review URL from the API response', async () => {
|
|||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Runs `auth login` against a stub Forgejo server and captures the body of
|
||||
// the token-creation request. GETs answer as /user; the POST to
|
||||
// /users/{name}/tokens is what carries the scopes under test.
|
||||
function runLoginWithServer(extraArgs) {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-${runLoginWithServer.n}.json`);
|
||||
runLoginWithServer.n += 1;
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
return new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
let tokenBody = null;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url.startsWith('/api/v1/users/')) {
|
||||
let data = '';
|
||||
req.on('data', (c) => { data += c; });
|
||||
req.on('end', () => {
|
||||
tokenBody = JSON.parse(data);
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 1, name: tokenBody.name, sha1: 'tok123' }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ login: 'alice', username: 'alice', email: 'alice@forge.test' }));
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['auth', 'login', '-u', `http://127.0.0.1:${port}`, '-n', 'alice', '-p', 'secret', ...extraArgs],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve({ res, tokenBody, cfg }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
}
|
||||
runLoginWithServer.n = 0;
|
||||
|
||||
test('auth login creates a token with the reduced default scopes', async () => {
|
||||
const { res, tokenBody, cfg } = await runLoginWithServer([]);
|
||||
try {
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(tokenBody.scopes, [
|
||||
'read:issue', 'write:issue',
|
||||
'read:repository', 'write:repository',
|
||||
'read:user',
|
||||
'read:organization',
|
||||
]);
|
||||
assert.match(res.stdout, /Scopes: read:issue, write:issue, read:repository, write:repository, read:user, read:organization/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('auth login --full-scopes restores the full scope set', async () => {
|
||||
const { res, tokenBody, cfg } = await runLoginWithServer(['--full-scopes']);
|
||||
try {
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(tokenBody.scopes, [
|
||||
'read:activitypub', 'write:activitypub',
|
||||
'read:issue', 'write:issue',
|
||||
'read:misc', 'write:misc',
|
||||
'read:organization', 'write:organization',
|
||||
'read:package', 'write:package',
|
||||
'read:repository', 'write:repository',
|
||||
'read:user', 'write:user',
|
||||
]);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('auth login --scopes parses a comma-separated list', async () => {
|
||||
const { res, tokenBody, cfg } = await runLoginWithServer(['--scopes', 'read:issue, write:repository ,read:user']);
|
||||
try {
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(tokenBody.scopes, ['read:issue', 'write:repository', 'read:user']);
|
||||
assert.match(res.stdout, /Scopes: read:issue, write:repository, read:user/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('auth login rejects --full-scopes together with --scopes before any network call', () => {
|
||||
const res = run(['auth', 'login', '--full-scopes', '--scopes', 'read:issue']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /either --full-scopes or --scopes, not both/);
|
||||
});
|
||||
|
||||
test('issue show validates --number before any network call', () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||
try {
|
||||
const res = run(['issue', 'show', '-o', 'o', '-r', 'r', '-n', 'zero'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Id must be a positive integer/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue comment rejects a missing body before any network call', () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||
try {
|
||||
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Comment body is required/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue comment rejects a whitespace-only body before any network call', () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||
try {
|
||||
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1', '-b', ' '], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Comment body is required/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue comment posts to the issue comments endpoint', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-ic.json`);
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
let request = null;
|
||||
const server = http.createServer((req, res) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => { data += c; });
|
||||
req.on('end', () => {
|
||||
request = { url: req.url, body: data };
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 9, html_url: 'https://forge.test/issues/7#issuecomment-9' }));
|
||||
});
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '7', '-b', 'Confirmed.'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve({ res, request }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
assert.equal(result.res.status, 0, result.res.stderr);
|
||||
assert.equal(result.request.url, '/api/v1/repos/o/r/issues/7/comments');
|
||||
assert.equal(JSON.parse(result.request.body).body, 'Confirmed.');
|
||||
assert.match(result.res.stdout, /Comment added to #7\./);
|
||||
assert.match(result.res.stdout, /URL: https:\/\/forge\.test\/issues\/7#issuecomment-9/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('read commands print the raw API JSON with --json', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-json.json`);
|
||||
const payloads = {
|
||||
'/api/v1/user': { login: 'bot' },
|
||||
'/api/v1/user/repos': [{ full_name: 'o/r' }],
|
||||
'/api/v1/repos/o/r/issues': [{ number: 7, title: 'Bug' }],
|
||||
'/api/v1/repos/o/r/issues/7': { number: 7, title: 'Bug', state: 'open' },
|
||||
'/api/v1/repos/o/r/pulls': [{ number: 3, title: 'Fix' }],
|
||||
'/api/v1/repos/o/r/pulls/3': { number: 3, title: 'Fix', state: 'open' },
|
||||
};
|
||||
const commands = [
|
||||
[['auth', 'status', '--json'], { login: 'bot' }],
|
||||
[['repo', 'list', '--json'], [{ full_name: 'o/r' }]],
|
||||
[['issue', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 7, title: 'Bug' }]],
|
||||
[['issue', 'show', '-o', 'o', '-r', 'r', '-n', '7', '--json'], { number: 7, title: 'Bug', state: 'open' }],
|
||||
[['pr', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 3, title: 'Fix' }]],
|
||||
[['pr', 'show', '-o', 'o', '-r', 'r', '-n', '3', '--json'], { number: 3, title: 'Fix', state: 'open' }],
|
||||
];
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const results = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = new URL(req.url, 'http://localhost').pathname;
|
||||
const payload = payloads[pathname];
|
||||
if (!payload) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: `no mock for ${pathname}` }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const out = [];
|
||||
for (const [args] of commands) {
|
||||
out.push(await spawnAsync(args, { STOKE_CONFIG_FILE: cfg }));
|
||||
}
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve(out));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
results.forEach((res, i) => {
|
||||
const [args, expected] = commands[i];
|
||||
assert.equal(res.status, 0, `${args.join(' ')}: ${res.stderr}`);
|
||||
assert.deepEqual(JSON.parse(res.stdout), expected);
|
||||
});
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('pr review --commit sends commit_id only when given', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-commit.json`);
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const bodies = [];
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
const server = http.createServer((req, res) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => { data += c; });
|
||||
req.on('end', () => {
|
||||
bodies.push(JSON.parse(data));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 42 }));
|
||||
});
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const withCommit = await spawnAsync(
|
||||
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve', '--commit', 'abc123'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
const withoutCommit = await spawnAsync(
|
||||
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve({ withCommit, withoutCommit }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
assert.equal(result.withCommit.status, 0, result.withCommit.stderr);
|
||||
assert.equal(result.withoutCommit.status, 0, result.withoutCommit.stderr);
|
||||
assert.equal(bodies[0].commit_id, 'abc123');
|
||||
assert.ok(!('commit_id' in bodies[1]));
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
109
test/clone.test.js
Normal file
109
test/clone.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync, execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
|
||||
const TOKEN = 'stoke-secret-token-for-clone-tests';
|
||||
|
||||
// A local stand-in for the forge: a directory holding bare repositories laid
|
||||
// out as <owner>/<repo>.git, so config.url can point at it with a file:// URL
|
||||
// and `repo clone` exercises real git clones without any network.
|
||||
let root;
|
||||
let remote;
|
||||
let work;
|
||||
let cfg;
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd: cwd || root, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function run(args, cwd) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], {
|
||||
cwd: cwd || work,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, STOKE_CONFIG_FILE: cfg },
|
||||
});
|
||||
}
|
||||
|
||||
before(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-clone-test-'));
|
||||
remote = path.join(root, 'remote');
|
||||
work = path.join(root, 'work');
|
||||
const seed = path.join(root, 'seed');
|
||||
fs.mkdirSync(path.join(remote, 'o'), { recursive: true });
|
||||
fs.mkdirSync(work);
|
||||
|
||||
git(['init', '-b', 'main', seed]);
|
||||
fs.writeFileSync(path.join(seed, 'README.md'), 'hello from seed\n');
|
||||
git(['-C', seed, 'add', 'README.md']);
|
||||
git(['-C', seed, '-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', 'initial']);
|
||||
git(['clone', '--bare', seed, path.join(remote, 'o', 'r.git')]);
|
||||
|
||||
cfg = path.join(root, 'config.json');
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `file://${remote}`, token: TOKEN, login: 'tester' }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('repo clone rejects an invalid --depth before running git', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--depth', 'zero']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Depth must be a positive integer/);
|
||||
});
|
||||
|
||||
test('repo clone defaults the destination to the repository name', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r']);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const dest = path.join(work, 'r');
|
||||
assert.ok(fs.existsSync(path.join(dest, '.git')));
|
||||
assert.equal(fs.readFileSync(path.join(dest, 'README.md'), 'utf8'), 'hello from seed\n');
|
||||
});
|
||||
|
||||
test('repo clone honors an explicit destination directory', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'custom-dir']);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.ok(fs.existsSync(path.join(work, 'custom-dir', '.git')));
|
||||
});
|
||||
|
||||
test('repo clone fails with git\'s status when the destination is not empty', () => {
|
||||
const dest = path.join(work, 'occupied');
|
||||
fs.mkdirSync(dest);
|
||||
fs.writeFileSync(path.join(dest, 'file.txt'), 'in the way\n');
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'occupied']);
|
||||
assert.equal(res.status, 128);
|
||||
assert.match(res.stderr, /already exists and is not an empty directory/);
|
||||
});
|
||||
|
||||
test('repo clone propagates git\'s failure for a missing repository', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
|
||||
assert.equal(res.status, 128);
|
||||
assert.match(res.stderr, /does not appear to be a git repository|repository.*does not exist/i);
|
||||
});
|
||||
|
||||
test('repo clone --origin sets the remote name', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--origin', 'upstream', 'named-origin']);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const url = git(['-C', path.join(work, 'named-origin'), 'config', 'remote.upstream.url']);
|
||||
assert.ok(url.trim().endsWith('/o/r.git'));
|
||||
});
|
||||
|
||||
test('repo clone never exposes the token in output or repository config', () => {
|
||||
const ok = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'redacted']);
|
||||
assert.equal(ok.status, 0, ok.stderr);
|
||||
const fail = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
|
||||
|
||||
for (const output of [ok.stdout, ok.stderr, fail.stdout, fail.stderr]) {
|
||||
assert.ok(!output.includes(TOKEN), 'token leaked into CLI output');
|
||||
}
|
||||
|
||||
const dest = path.join(work, 'redacted');
|
||||
const gitConfig = fs.readFileSync(path.join(dest, '.git', 'config'), 'utf8');
|
||||
assert.ok(!gitConfig.includes(TOKEN), 'token persisted in .git/config');
|
||||
const remoteUrl = git(['-C', dest, 'config', 'remote.origin.url']);
|
||||
assert.ok(!remoteUrl.includes(TOKEN), 'token persisted in the remote URL');
|
||||
});
|
||||
269
test/governance.test.js
Normal file
269
test/governance.test.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-governance.js');
|
||||
const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf');
|
||||
const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
|
||||
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
|
||||
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
|
||||
const PACKAGE_MANIFEST = path.join(__dirname, '..', 'package.json');
|
||||
const PACKAGE_LOCK = path.join(__dirname, '..', 'package-lock.json');
|
||||
const CEREMONY_REPOSITORY = 'https://forgejo.heavyduty.builders/heavy-duty/ceremony';
|
||||
const CEREMONY_VERSION = '0.6.3';
|
||||
const CEREMONY_WORKFLOWS = ['labels.yml', 'labels-sweep.yml'];
|
||||
const cleanups = [];
|
||||
process.on('exit', () => {
|
||||
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeConfig(contents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-test-'));
|
||||
cleanups.push(dir);
|
||||
const config = path.join(dir, 'labels.conf');
|
||||
fs.writeFileSync(config, contents);
|
||||
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
|
||||
return config;
|
||||
}
|
||||
|
||||
function writeRepository(configContents, labelerContents, files = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-repository-'));
|
||||
cleanups.push(dir);
|
||||
fs.mkdirSync(path.join(dir, '.github'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.github', 'labels.conf'), configContents);
|
||||
fs.writeFileSync(path.join(dir, '.github', 'labeler.yml'), labelerContents);
|
||||
for (const [filename, contents] of Object.entries(files)) {
|
||||
const target = path.join(dir, filename);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, contents);
|
||||
}
|
||||
execFileSync('git', ['init', '-q'], { cwd: dir });
|
||||
execFileSync('git', ['add', '.'], { cwd: dir });
|
||||
return { dir, config: path.join(dir, '.github', 'labels.conf') };
|
||||
}
|
||||
|
||||
function runValidator(config, apiUrl, cwd = path.join(__dirname, '..')) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function withIdentityServer(logins, callback, redirects = new Map()) {
|
||||
const server = http.createServer((request, response) => {
|
||||
const login = decodeURIComponent(request.url.replace('/api/v1/users/', ''));
|
||||
if (redirects.has(login)) {
|
||||
response.writeHead(307, { location: `/api/v1/users/${redirects.get(login)}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(logins.has(login) ? 200 : 404, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(logins.has(login) ? { login } : { message: 'not found' }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
try {
|
||||
await callback(`http://127.0.0.1:${port}/api/v1`);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
const validConfig = [
|
||||
'panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl',
|
||||
'triage-actors=claude-bot-andresmgsl',
|
||||
'scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)',
|
||||
'scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path',
|
||||
'scope:manifests|C5DEF5|manifests/ — the fleet repo registry data',
|
||||
'scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates',
|
||||
'scope:docs|C5DEF5|README and docs/ — the prose contract',
|
||||
].join('\n');
|
||||
|
||||
test('governance validator accepts the configured roster when every identity resolves', async () => {
|
||||
const config = writeConfig(`${validConfig}\n`);
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(config, apiUrl);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
|
||||
});
|
||||
});
|
||||
|
||||
test('governance validator fails when a roster identity does not resolve', async () => {
|
||||
const config = writeConfig(`${validConfig.replace('kimi-bot-andresmgsl', 'kimi-bto-andresmgsl')}\n`);
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(config, apiUrl);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /kimi-bto-andresmgsl.*HTTP 404/);
|
||||
});
|
||||
});
|
||||
|
||||
test('governance validator rejects a renamed identity that redirects to a live login', async () => {
|
||||
const config = writeConfig(`${validConfig.replaceAll('claude-bot-andresmgsl', 'cluade-bot-andresmgsl')}\n`);
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
const redirects = new Map([['cluade-bot-andresmgsl', 'claude-bot-andresmgsl']]);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(config, apiUrl);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /cluade-bot-andresmgsl.*HTTP 307/);
|
||||
}, redirects);
|
||||
});
|
||||
|
||||
test('governance validator rejects malformed scope rows before identity requests', async () => {
|
||||
const config = writeConfig(`${validConfig.replace('|C5DEF5|', '|not-a-color|')}\n`);
|
||||
const result = await runValidator(config, 'http://127.0.0.1:1/api/v1');
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /malformed label row/);
|
||||
assert.doesNotMatch(result.stderr, /fetch failed/);
|
||||
});
|
||||
|
||||
test('governance validator rejects a tracked path outside every scope and the residue allowlist', async () => {
|
||||
const config = [
|
||||
'panel=codex-bot-andresmgsl',
|
||||
'scope:cli|C5DEF5|src/ — command surface',
|
||||
'scope:ci|C5DEF5|.github/ — governance surface',
|
||||
].join('\n');
|
||||
const labeler = [
|
||||
'"scope:cli":',
|
||||
' - changed-files:',
|
||||
' - any-glob-to-any-file: ["src/**"]',
|
||||
'"scope:ci":',
|
||||
' - changed-files:',
|
||||
' - any-glob-to-any-file: [".github/**"]',
|
||||
].join('\n');
|
||||
const repository = writeRepository(`${config}\n`, `${labeler}\n`, {
|
||||
'src/covered.js': '',
|
||||
'new-surface/uncovered.txt': '',
|
||||
});
|
||||
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
||||
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /tracked paths have no scope mapping: new-surface\/uncovered\.txt/);
|
||||
});
|
||||
});
|
||||
|
||||
test('governance validator rejects scope names declared only in the labeler map', async () => {
|
||||
const config = [
|
||||
'panel=codex-bot-andresmgsl',
|
||||
'scope:cli|C5DEF5|src/ — command surface',
|
||||
].join('\n');
|
||||
const labeler = [
|
||||
'"scope:cli":',
|
||||
' - changed-files:',
|
||||
' - any-glob-to-any-file: ["src/**"]',
|
||||
'"scope:extra":',
|
||||
' - changed-files:',
|
||||
' - any-glob-to-any-file: ["extra/**"]',
|
||||
].join('\n');
|
||||
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
|
||||
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
||||
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /scope names only in labeler\.yml: scope:extra/);
|
||||
});
|
||||
});
|
||||
|
||||
test('governance validator rejects scope names declared only in labels.conf', async () => {
|
||||
const config = [
|
||||
'panel=codex-bot-andresmgsl',
|
||||
'scope:cli|C5DEF5|src/ — command surface',
|
||||
'scope:renamed|C5DEF5|renamed/ — renamed surface',
|
||||
].join('\n');
|
||||
const labeler = [
|
||||
'"scope:cli":',
|
||||
' - changed-files:',
|
||||
' - any-glob-to-any-file: ["src/**"]',
|
||||
].join('\n');
|
||||
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
|
||||
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
||||
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /scope names only in labels\.conf: scope:renamed/);
|
||||
});
|
||||
});
|
||||
|
||||
test('repository governance config resolves the current four-member panel and five scopes', async () => {
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(REPOSITORY_CONFIG, apiUrl);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
|
||||
});
|
||||
});
|
||||
|
||||
test('repository scope mapping covers every tracked path except the ruled residue', () => {
|
||||
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
|
||||
const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
|
||||
.flatMap((match) => JSON.parse(match[1]));
|
||||
const tracked = execFileSync('git', ['ls-files'], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
encoding: 'utf8',
|
||||
}).trim().split('\n');
|
||||
const allowed = new Set([
|
||||
'.gitignore',
|
||||
'assets/logo-mark.svg',
|
||||
...tracked.filter((filename) => /^test\/[^/]+\.test\.js$/.test(filename)),
|
||||
]);
|
||||
const uncovered = tracked.filter((filename) => (
|
||||
!allowed.has(filename) && !globs.some((glob) => path.matchesGlob(filename, glob))
|
||||
));
|
||||
|
||||
assert.deepEqual(uncovered, []);
|
||||
});
|
||||
|
||||
test('package lock versions match the package manifest', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(PACKAGE_MANIFEST, 'utf8'));
|
||||
const lock = JSON.parse(fs.readFileSync(PACKAGE_LOCK, 'utf8'));
|
||||
|
||||
assert.equal(lock.version, manifest.version, 'package-lock.json version is stale');
|
||||
assert.equal(lock.packages[''].version, manifest.version, 'package-lock.json root package version is stale');
|
||||
});
|
||||
|
||||
test('repository carries the complete Forgejo 0.6.3 doctrine mirror and root router', () => {
|
||||
const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
|
||||
for (const filename of vendored) {
|
||||
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`);
|
||||
}
|
||||
const mirrorReadme = fs.readFileSync(path.join(REPOSITORY_MIRROR, 'README.md'), 'utf8');
|
||||
const sourceVersionRecord = `[heavy-duty/ceremony](${CEREMONY_REPOSITORY}) at ${CEREMONY_VERSION}`;
|
||||
assert.equal(
|
||||
mirrorReadme.split(sourceVersionRecord).length - 1,
|
||||
2,
|
||||
'mirror README does not identify the exact Forgejo ceremony source and version in both records',
|
||||
);
|
||||
assert.match(mirrorReadme, /labels doctrine is vendored manually/);
|
||||
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
|
||||
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/);
|
||||
const rootAgents = fs.readFileSync(ROOT_AGENTS, 'utf8');
|
||||
assert.ok(
|
||||
rootAgents.includes(`[heavy-duty/ceremony](${CEREMONY_REPOSITORY})`),
|
||||
'root router does not identify the Forgejo ceremony repository',
|
||||
);
|
||||
assert.match(rootAgents, /read\s+`.ceremony\/AGENTS\.md` first/i);
|
||||
});
|
||||
|
||||
test('repository workflow pins use the exact Forgejo ceremony version', () => {
|
||||
for (const workflow of CEREMONY_WORKFLOWS) {
|
||||
const contents = fs.readFileSync(path.join(__dirname, '..', '.forgejo', 'workflows', workflow), 'utf8');
|
||||
const prefix = `uses: heavy-duty/ceremony/.github/workflows/${workflow}@`;
|
||||
const pins = contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith(prefix));
|
||||
assert.deepEqual(
|
||||
pins,
|
||||
[`${prefix}${CEREMONY_VERSION}`],
|
||||
`${workflow} does not pin ceremony ${CEREMONY_VERSION}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
248
test/import-batch.test.js
Normal file
248
test/import-batch.test.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -12,12 +12,17 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh');
|
|||
// candInitial `apt-cache policy` Candidate before any update
|
||||
// candAfterUpdate Candidate after any `apt-get update`
|
||||
// candAfterNodesource Candidate after an update once nodesource.list exists
|
||||
// releaseStatus HTTP status curl reports for the registry Release file
|
||||
// sourceUpdateError stderr and exit 100 for the first signed stoke update
|
||||
// forgeUser/token private-registry credentials
|
||||
// allowUnverified explicit HTTPS-only integrity opt-in
|
||||
// precreateSourcesDir whether the throwaway apt root already has sources.list.d
|
||||
// The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set,
|
||||
// so every scenario doubles as a regression test for locale-safe parsing.
|
||||
const cleanups = [];
|
||||
process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList }) {
|
||||
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified, precreateSourcesDir = true }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-'));
|
||||
cleanups.push(root);
|
||||
const bin = path.join(root, 'bin');
|
||||
|
|
@ -25,7 +30,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
|||
const aptEtc = path.join(root, 'etc', 'apt');
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
fs.mkdirSync(state, { recursive: true });
|
||||
fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true });
|
||||
if (precreateSourcesDir) fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true });
|
||||
fs.writeFileSync(path.join(state, 'candidate'), candInitial);
|
||||
if (preexistingNodesourceList !== undefined) {
|
||||
fs.writeFileSync(path.join(aptEtc, 'sources.list.d', 'nodesource.list'), preexistingNodesourceList);
|
||||
|
|
@ -38,8 +43,35 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
|||
|
||||
// Force the non-root path so every mutation goes through the sudo stub.
|
||||
stub('id', 'echo 1000');
|
||||
stub('sudo', 'exec "$@"');
|
||||
stub('curl', 'echo "FAKE-KEY"');
|
||||
stub('sudo', 'SUDO_ACTIVE=1 exec "$@"');
|
||||
stub('tee', [
|
||||
'for destination in "$@"; do',
|
||||
' case "$destination" in',
|
||||
' */auth.conf.d/forgejo-*.conf)',
|
||||
' mode="$(stat -c %a "$destination" 2>/dev/null || true)"',
|
||||
' if [ "$mode" != 600 ]; then',
|
||||
' echo "credential destination was not mode 0600 before write" >&2',
|
||||
' exit 78',
|
||||
' fi',
|
||||
' ;;',
|
||||
' esac',
|
||||
'done',
|
||||
'exec /usr/bin/tee "$@"',
|
||||
].join('\n'));
|
||||
// Registry Release-file probes (URLs under /dists/) answer with the
|
||||
// scenario's HTTP status; everything else is a key fetch.
|
||||
stub('curl', [
|
||||
'uses_netrc=false',
|
||||
'for a in "$@"; do [ "$a" = "--netrc-file" ] && uses_netrc=true; done',
|
||||
'if [ "$uses_netrc" = true ] && [ "${SUDO_ACTIVE:-}" != 1 ]; then',
|
||||
' echo "curl: root-owned netrc is unreadable without sudo" >&2',
|
||||
' exit 77',
|
||||
'fi',
|
||||
'for a in "$@"; do',
|
||||
' case "$a" in */dists/*) echo "${RELEASE_STATUS:-200}"; exit 0;; esac',
|
||||
'done',
|
||||
'echo "FAKE-KEY"',
|
||||
].join('\n'));
|
||||
stub('stoke', 'echo 1.2.0');
|
||||
stub('apt-cache', [
|
||||
'cand="$(cat "$STATE_DIR/candidate")"',
|
||||
|
|
@ -50,8 +82,16 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
|||
].join('\n'));
|
||||
stub('apt-get', [
|
||||
'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"',
|
||||
'source_list=""',
|
||||
'for a in "$@"; do',
|
||||
' case "$a" in Dir::Etc::sourcelist=*) source_list="${a#*=}";; esac',
|
||||
'done',
|
||||
'for a in "$@"; do',
|
||||
' if [ "$a" = update ]; then',
|
||||
' if [ -n "$source_list" ] && grep -q "signed-by=" "$source_list" && [ -n "${SOURCE_UPDATE_ERROR:-}" ]; then',
|
||||
' printf "%s\\n" "$SOURCE_UPDATE_ERROR" >&2',
|
||||
' exit 100',
|
||||
' fi',
|
||||
' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then',
|
||||
' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"',
|
||||
' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then',
|
||||
|
|
@ -73,6 +113,11 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
|||
STATE_DIR: state,
|
||||
CAND_AFTER_UPDATE: candAfterUpdate || '',
|
||||
CAND_AFTER_NODESOURCE: candAfterNodesource || '',
|
||||
RELEASE_STATUS: releaseStatus || '',
|
||||
SOURCE_UPDATE_ERROR: sourceUpdateError || '',
|
||||
FORGE_USER: forgeUser || '',
|
||||
FORGE_TOKEN: forgeToken || '',
|
||||
STOKE_ALLOW_UNVERIFIED_APT: allowUnverified || '',
|
||||
LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C
|
||||
},
|
||||
});
|
||||
|
|
@ -87,6 +132,9 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
|||
nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
|
||||
nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
|
||||
forgeKeyMode: mode(path.join(aptEtc, 'keyrings', 'forgejo-heavy-duty.asc')),
|
||||
forgeList: read(path.join(aptEtc, 'sources.list.d', 'forgejo-heavy-duty.list')),
|
||||
forgeAuth: read(path.join(aptEtc, 'auth.conf.d', 'forgejo-heavy-duty.conf')),
|
||||
forgeAuthMode: mode(path.join(aptEtc, 'auth.conf.d', 'forgejo-heavy-duty.conf')),
|
||||
aptGetLog: read(path.join(state, 'apt-get.log')) || '',
|
||||
};
|
||||
// Drop the throwaway tree after we have read everything we need.
|
||||
|
|
@ -147,3 +195,133 @@ test('pre-existing user-managed nodesource.list is never overwritten', () => {
|
|||
assert.equal(s.nodesourceList, marker);
|
||||
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('registry Release file 404s: fails fast with a clear message before apt runs', () => {
|
||||
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '404' });
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.match(s.res.stderr, /no stoke package has been published/);
|
||||
assert.match(s.res.stderr, /npm/);
|
||||
assert.match(s.res.stderr, /dists\/stable\/Release returned 404/);
|
||||
assert.equal(s.aptGetLog, '', 'must abort before any apt-get invocation');
|
||||
});
|
||||
|
||||
test('registry Release file present: proceeds with the install', () => {
|
||||
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '200' });
|
||||
assert.equal(s.res.status, 0, s.res.stderr);
|
||||
assert.match(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('fresh apt root creates sources.list.d before writing the forge source', () => {
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
precreateSourcesDir: false,
|
||||
});
|
||||
assert.equal(s.res.status, 0, s.res.stderr);
|
||||
assert.match(s.forgeList, /\[signed-by=/);
|
||||
assert.match(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('signature verification failure refuses by default and removes the forge source', () => {
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
sourceUpdateError: 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set',
|
||||
});
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.equal(s.forgeList, null);
|
||||
assert.match(s.res.stderr, /sqv-based apt cannot parse\s+the Forgejo registry signature/);
|
||||
assert.match(s.res.stderr, /STOKE_ALLOW_UNVERIFIED_APT=1/);
|
||||
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('exact opt-in permits an HTTPS-only forge source after signature failure', () => {
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
sourceUpdateError: 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set',
|
||||
allowUnverified: '1',
|
||||
});
|
||||
assert.equal(s.res.status, 0, s.res.stderr);
|
||||
assert.match(s.forgeList, /\[trusted=yes\]/);
|
||||
assert.match(s.res.stderr, /OpenPGP signature verification is disabled/);
|
||||
assert.match(s.res.stderr, /HTTPS-only integrity/);
|
||||
assert.match(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('opt-in cannot bypass a missing signing key', () => {
|
||||
const failure = 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.';
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
sourceUpdateError: failure,
|
||||
allowUnverified: '1',
|
||||
});
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.match(s.res.stderr, new RegExp(failure.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(s.forgeList, /\[signed-by=/);
|
||||
assert.doesNotMatch(s.forgeList, /trusted=yes/);
|
||||
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('opt-in cannot bypass sqv output without the known Malformed MPI failure', () => {
|
||||
const failure = 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1): unexpected packet';
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
sourceUpdateError: failure,
|
||||
allowUnverified: '1',
|
||||
});
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.match(s.res.stderr, new RegExp(failure.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(s.forgeList, /\[signed-by=/);
|
||||
assert.doesNotMatch(s.forgeList, /trusted=yes/);
|
||||
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('unrecognized opt-in value is rejected before configuring apt', () => {
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
allowUnverified: 'yes',
|
||||
});
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.match(s.res.stderr, /STOKE_ALLOW_UNVERIFIED_APT must be unset or exactly 1/);
|
||||
assert.equal(s.forgeList, null);
|
||||
assert.equal(s.aptGetLog, '');
|
||||
});
|
||||
|
||||
test('network update failure stays fatal and never disables signature verification', () => {
|
||||
const failure = 'Temporary failure resolving forgejo.heavyduty.builders';
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
sourceUpdateError: failure,
|
||||
});
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.match(s.res.stderr, new RegExp(failure));
|
||||
assert.match(s.forgeList, /\[signed-by=/);
|
||||
assert.doesNotMatch(s.forgeList, /trusted=yes/);
|
||||
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('private-registry credentials stay in a root-readable auth file, not the source URL', () => {
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
forgeUser: 'apt-user',
|
||||
forgeToken: 'secret-token',
|
||||
});
|
||||
assert.equal(s.res.status, 0, s.res.stderr);
|
||||
assert.equal(s.forgeAuthMode, 0o600);
|
||||
assert.equal(s.forgeAuth, [
|
||||
'machine forgejo.heavyduty.builders',
|
||||
'login apt-user',
|
||||
'password secret-token',
|
||||
'',
|
||||
].join('\n'));
|
||||
assert.doesNotMatch(s.forgeList, /apt-user|secret-token/);
|
||||
});
|
||||
|
||||
test('incomplete private-registry credentials fail before configuring apt', () => {
|
||||
const s = runScenario({
|
||||
candInitial: '22.23.1-1nodesource1',
|
||||
forgeUser: 'apt-user',
|
||||
});
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.match(s.res.stderr, /FORGE_USER and FORGE_TOKEN must be set together/);
|
||||
assert.equal(s.forgeList, null);
|
||||
assert.equal(s.aptGetLog, '');
|
||||
});
|
||||
|
|
|
|||
158
test/publish-deb.test.js
Normal file
158
test/publish-deb.test.js
Normal 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);
|
||||
});
|
||||
114
test/publish-release.test.js
Normal file
114
test/publish-release.test.js
Normal 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
327
test/sync.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue