Compare commits
80 commits
build/25-r
...
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 |
47 changed files with 2366 additions and 164 deletions
|
|
@ -14,7 +14,7 @@ reviewer here"). That one word is your whole onboarding:
|
||||||
|
|
||||||
| you are the… | read | your job in one line |
|
| you are the… | read | your job in one line |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
|
||||||
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
| **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 |
|
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ are the shared state machine, and misusing one lies to every other agent on
|
||||||
the board.
|
the board.
|
||||||
|
|
||||||
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||||
asked to implement an issue → builder; asked to process discussions or the
|
asked to implement an issue → builder; asked to process proposals or the
|
||||||
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
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
|
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||||
pushing fixes, breaks the separation the pipeline depends on.
|
pushing fixes, breaks the separation the pipeline depends on.
|
||||||
|
|
@ -31,13 +31,13 @@ pushing fixes, breaks the separation the pipeline depends on.
|
||||||
## The pipeline you are part of
|
## The pipeline you are part of
|
||||||
|
|
||||||
```
|
```
|
||||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||||
```
|
```
|
||||||
|
|
||||||
Two rules bind every role:
|
Two rules bind every role:
|
||||||
|
|
||||||
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
- **Only triage mints work issues.** Found work? File or extend a proposal.
|
||||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||||
a merge button.
|
a merge button.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,10 @@ triage bug, and the move is to say so on the issue, not to guess.
|
||||||
re-requests posted, by head and not by verdict (steps 1–2). A red check
|
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;
|
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
|
3. every remaining acceptance criterion is operator-owned, stated so by
|
||||||
triage on the issue;
|
triage on the issue. **An operator-owned remainder parks the claim and
|
||||||
|
never the handoff**: this shape is reached only from the far side of
|
||||||
|
shape 4, because it is the state finishing the work puts you in and
|
||||||
|
would otherwise excuse the handoff it should follow (#336);
|
||||||
4. it is **handed off** — round passed, no `blocker:*` standing,
|
4. it is **handed off** — round passed, no `blocker:*` standing,
|
||||||
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
|
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
|
||||||
and 4 are sequential and never overlap;
|
and 4 are sequential and never overlap;
|
||||||
|
|
@ -137,8 +140,8 @@ triage bug, and the move is to say so on the issue, not to guess.
|
||||||
its red-flag profile are in REVIEWER.md §What you review against, item 2
|
its red-flag profile are in REVIEWER.md §What you review against, item 2
|
||||||
(#216).
|
(#216).
|
||||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||||
Adjacent problems go to a discussion, or a comment on the relevant issue;
|
Adjacent problems go to a proposal, or a comment on the relevant issue;
|
||||||
you do not mint issues — nobody but triage does — and you do not fix
|
you do not mint work issues — nobody but triage does — and you do not fix
|
||||||
drive-by findings in the same PR.
|
drive-by findings in the same PR.
|
||||||
|
|
||||||
## The review round
|
## The review round
|
||||||
|
|
@ -174,7 +177,9 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
|
||||||
blocking (#139, #276). **Then classify that entry by `conclusion`, never
|
blocking (#139, #276). **Then classify that entry by `conclusion`, never
|
||||||
`status`**, which can disagree with it (#259). No conclusion is not
|
`status`**, which can disagree with it (#259). No conclusion is not
|
||||||
green: a configured run in progress is waited on, and waiting is
|
green: a configured run in progress is waited on, and waiting is
|
||||||
compliance, not a stall. Cancelled or stale is not green, *stale* being a
|
compliance, not a stall — the wait is the **request's**, and never a
|
||||||
|
reason to withhold the declaration that a round was answered (step 2).
|
||||||
|
Cancelled or stale is not green, *stale* being a
|
||||||
superseded head's check, which a head-scoped rollup never shows. Skipped
|
superseded head's check, which a head-scoped rollup never shows. Skipped
|
||||||
or neutral is green, those being deliberate "passed / not applicable"
|
or neutral is green, those being deliberate "passed / not applicable"
|
||||||
conclusions. No checks configured is green — the third ruled case, not an
|
conclusions. No checks configured is green — the third ruled case, not an
|
||||||
|
|
@ -200,8 +205,19 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
|
||||||
head, and its mechanism is crew's to describe (#94). **The re-request
|
head, and its mechanism is crew's to describe (#94). **The re-request
|
||||||
carries the same green-check-at-head precondition**, argued exception
|
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
|
included: a fix push whose check comes up red is your next fix, not the
|
||||||
panel's. Prefer verification over argument — add the test that settles
|
panel's. **Where an engine mediates the request, that precondition binds
|
||||||
the doubt.
|
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.
|
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
|
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
|
in the PR; silence and force-forward are not options, and a panel
|
||||||
|
|
@ -218,8 +234,9 @@ may draft a PR but only the builder undrafts it. **Where a draft suppressed
|
||||||
the checks, green is proven at the flip and the request still follows it** —
|
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
|
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.
|
the head answer, then request, step 1's precondition and not a second one.
|
||||||
Waiting there is compliance, and `blocker:unrequested` does not fire while a
|
Waiting there is compliance — again the request's wait, not the
|
||||||
head's checks are pending or red (#236).
|
declaration's — and `blocker:unrequested` does not fire while a head's
|
||||||
|
checks are pending or red (#236).
|
||||||
|
|
||||||
## The ruling ask
|
## The ruling ask
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ and the reconciler recomputes it from GitHub's own facts.
|
||||||
`state:needs-human` means exactly one thing — a human could merge this now —
|
`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
|
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
|
the reconciler takes it back. The author sets it at handoff (the one
|
||||||
hand-set state); the `labeled` event fires the sweep that validates the
|
hand-set state). On a same-repository head, the `labeled` event fires the
|
||||||
write within seconds.
|
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)
|
## PR blockers — what is in the way? (facts, as many as apply)
|
||||||
|
|
||||||
|
|
@ -49,7 +51,7 @@ strips it on sight).
|
||||||
|
|
||||||
| Label | Color | Means | Set by |
|
| Label | Color | Means | Set by |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
| `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
|
||||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
| `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 |
|
| `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 |
|
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||||
|
|
@ -240,4 +242,4 @@ full-board reconcile. The sweep warns when the core taxonomy declares a label
|
||||||
the repository lacks. The same workflow reconciles issue-flow labels on issue
|
the repository lacks. The same workflow reconciles issue-flow labels on issue
|
||||||
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
|
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
|
||||||
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
|
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
|
||||||
deleted at bootstrap — a `question` is a discussion, not an issue.
|
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
Never edit these files ad hoc. The six manifest-listed doctrine files are
|
Never edit these files ad hoc. The six manifest-listed doctrine files are
|
||||||
byte-identical copies of
|
byte-identical copies of
|
||||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at 0.6.1, but
|
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony) at 0.6.3, but
|
||||||
stoke does not run `docs-sync` or re-diff the mirror in CI. Change doctrine
|
stoke does not run `docs-sync` or re-diff the mirror in CI. Change doctrine
|
||||||
upstream through its own flow, then re-vendor it here when the pin moves.
|
upstream through its own flow, then re-vendor it here when the pin moves.
|
||||||
|
|
||||||
The labels doctrine is vendored manually from heavy-duty/ceremony at 0.6.1
|
The labels doctrine is vendored manually from
|
||||||
because stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
|
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony) at 0.6.3 because
|
||||||
|
stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
|
||||||
`docs-sync --fix` cannot run until or unless a future issue adopts the
|
`docs-sync --fix` cannot run until or unless a future issue adopts the
|
||||||
ceremony release-workflow pin; until then, doctrine updates must re-vendor
|
ceremony release-workflow pin; until then, doctrine updates must re-vendor
|
||||||
the pinned manifest manually.
|
the pinned manifest manually.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,59 @@ decision during release-init. The double gate on
|
||||||
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
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.
|
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
|
## Release-init
|
||||||
|
|
||||||
The predecessor closing and clearing the next epic's declared gate is the
|
The predecessor closing and clearing the next epic's declared gate is the
|
||||||
|
|
@ -54,7 +107,9 @@ steps:
|
||||||
written. Each member initially declares `Blocked by <the epic>`.
|
written. Each member initially declares `Blocked by <the epic>`.
|
||||||
2. Graph hard `Blocked by` edges and same-file clusters on 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
|
3. Write the waves into the epic body as checklists in claim order, with a
|
||||||
separate verification lane and the progress view under `## Task list`.
|
separate verification lane and the progress view under `## Task list`, and
|
||||||
|
write the window's membership under `## Members` — release-init is where
|
||||||
|
that record is first written, and until it exists no window stands.
|
||||||
4. Ask the operator to bless the order, then have triage open the first wave
|
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
|
by applying the flip mechanics below. The operator's blessing is the one
|
||||||
step this chain never automates.
|
step this chain never automates.
|
||||||
|
|
@ -74,18 +129,50 @@ interleaving unrelated windows blurs both the release story and the evidence
|
||||||
behind it. Gates open windows; they do not silently admit members, so builders
|
behind it. Gates open windows; they do not silently admit members, so builders
|
||||||
still see one deliberately ordered queue.
|
still see one deliberately ordered queue.
|
||||||
|
|
||||||
While a window stands — an open release-labeled issue with a non-empty
|
While a window stands — an open release-labeled issue whose membership record
|
||||||
enumerated gate — its members form a DAG whose sink is the release issue.
|
holds at least one open member — its members form a DAG whose sink is the
|
||||||
Every member reaches that sink. Members declare only their immediate
|
release issue. Every member reaches that sink. Members declare only their
|
||||||
predecessors; ordering edges live on members, while the sink records membership
|
immediate predecessors; ordering edges live on members, while the sink records
|
||||||
only; and the `ready` set is exactly the graph's current sources. Every close
|
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
|
releases exactly its declared successors, and that whole set is concurrently
|
||||||
claimable: a member may have multiple successors, while the collision rule
|
claimable: a member may have multiple successors, while the collision rule
|
||||||
already orders any that share a deliverable. Insertion re-points downstream
|
already orders any that share a deliverable. Insertion re-points downstream
|
||||||
edges rather than merely appending membership at the sink. It follows that
|
edges rather than merely appending membership at the sink. It follows that
|
||||||
every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt
|
every `ready` issue is a member. `epic` and `post-merge` issues are exempt
|
||||||
because neither is claimable (#292).
|
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
|
The operator may declare a parallel track at init when its footprint is
|
||||||
disjoint from the primary window: another repository, another artifact, or
|
disjoint from the primary window: another repository, another artifact, or
|
||||||
provably non-overlapping clusters. The declaration names the boundary and any
|
provably non-overlapping clusters. The declaration names the boundary and any
|
||||||
|
|
@ -104,6 +191,13 @@ says they no longer apply. Preserve the history only after rewriting the
|
||||||
marker into non-parseable prose, then verify that the parser returns an empty
|
marker into non-parseable prose, then verify that the parser returns an empty
|
||||||
set for the release gate.
|
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
|
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
|
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.
|
ordinary issue dependencies, but it does not choose a release's contents.
|
||||||
|
|
|
||||||
|
|
@ -123,9 +123,9 @@ saw Y" outranks one that says "this looks like it might".
|
||||||
## What you do not do
|
## What you do not do
|
||||||
|
|
||||||
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||||
above it, in a discussion where humans had their say. If you think the
|
above it, in a proposal where humans had their say. If you think the
|
||||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||||
discussion, while still reviewing the implementation against the spec as
|
proposal, while still reviewing the implementation against the spec as
|
||||||
written. Spec changes go through triage, not through a review round.
|
written. Spec changes go through triage, not through a review round.
|
||||||
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||||
human; only humans merge.
|
human; only humans merge.
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
# TRIAGE.md — the triage role
|
# TRIAGE.md — the triage role
|
||||||
|
|
||||||
You are the only door issues come through. Humans and agents open **discussions**;
|
You are the only door work issues come through. Humans and agents file
|
||||||
you decide what becomes work and set the quality builders and reviewers receive.
|
**proposals**; you decide what becomes work and set the quality builders and
|
||||||
|
reviewers receive.
|
||||||
|
|
||||||
## Why this door exists
|
## Why this door exists
|
||||||
|
|
||||||
Discussions may be ambiguous; issues may not: a builder must be able to execute
|
Proposals may be ambiguous; work issues may not: a builder must be able to
|
||||||
one **without asking anything**. One accountable role keeps builders from guessing.
|
execute one **without asking anything**. One accountable role keeps builders
|
||||||
|
from guessing.
|
||||||
|
|
||||||
## Your inputs
|
## Your inputs
|
||||||
|
|
||||||
- **Every open discussion** in the repo you serve.
|
- **Every open proposal** in the repo you serve.
|
||||||
- **Stray issues** — anything filed directly, by anyone. Label it
|
- **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
|
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||||
substance back into a discussion and close it, saying why. Route the work
|
substance into a proposal and close it, saying why. Route the work
|
||||||
without shaming the filer.
|
without shaming the filer.
|
||||||
|
|
||||||
## For each discussion, converge on exactly one outcome
|
## For each proposal, converge on exactly one outcome
|
||||||
|
|
||||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
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.
|
already shipped or tracked. Link the code, doc, or issue; mark answered.
|
||||||
|
|
@ -32,9 +35,9 @@ one **without asking anything**. One accountable role keeps builders from guessi
|
||||||
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
||||||
including its options, recommendation, blocked/continues statement, and
|
including its options, recommendation, blocked/continues statement, and
|
||||||
reversible-only default rules (#50 D12–D13).
|
reversible-only default rules (#50 D12–D13).
|
||||||
The discussion is where humans decide; wait there. When the decision
|
The proposal is where humans decide; wait there. When the decision
|
||||||
blocks something already on the board — an existing issue, or minted work
|
blocks something already on the board — an existing issue, or minted work
|
||||||
a discussion's ruling gates — set `needs-ruling` on it too, so the board
|
a proposal's ruling gates — set `needs-ruling` on it too, so the board
|
||||||
shows where the human's turn is; the issue keeps its queue label.
|
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**,
|
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
|
name what it waits on, and set `attention` so the assignee's ack is visible
|
||||||
|
|
@ -61,7 +64,7 @@ one **without asking anything**. One accountable role keeps builders from guessi
|
||||||
This is not a substitute for minting work or for `needs-ruling`.
|
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
|
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
|
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||||
outcome; a zombie discussion is not.
|
outcome; a zombie proposal is not.
|
||||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||||
the bar.
|
the bar.
|
||||||
|
|
||||||
|
|
@ -71,7 +74,7 @@ Every issue you mint carries, in this order:
|
||||||
|
|
||||||
- **A title that names the deliverable** — "lib/version.sh — one version
|
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||||
abstraction, two backends", never "improve version handling".
|
abstraction, two backends", never "improve version handling".
|
||||||
- **Context**: why this exists, with links — the discussion it came from,
|
- **Context**: why this exists, with links — the proposal it came from,
|
||||||
the code it touches (permalinks at a pinned SHA, so line references cannot
|
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||||
rot), prior art in sibling repos.
|
rot), prior art in sibling repos.
|
||||||
- **The spec**: decisions made, not options listed. If the spec still has an
|
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||||
|
|
@ -106,9 +109,11 @@ Every issue you mint carries, in this order:
|
||||||
new issue, dropping any predecessor the new issue now reaches (inserting X
|
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
|
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 issue but already reaches it through another member declares nothing
|
||||||
new; and the release issue adds the new issue to its gate, recording
|
new; and the release issue adds a row for the new issue to its membership
|
||||||
membership only. Collision and window edges are independent, so write both
|
record, which records membership only and is the only place the sweep reads
|
||||||
when both apply (#292).
|
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
|
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||||
|
|
||||||
|
|
@ -157,4 +162,4 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
||||||
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||||
- Make the human's decisions (outcome 3 exists for those), or soften a
|
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||||
refusal into a vague issue to avoid saying no.
|
refusal into a vague issue to avoid saying no.
|
||||||
- Mint an issue to "discuss" something — that is a discussion.
|
- Mint a work issue to explore an idea — file a proposal instead.
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
jobs:
|
jobs:
|
||||||
sweep:
|
sweep:
|
||||||
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@0.6.1
|
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@0.6.3
|
||||||
with:
|
with:
|
||||||
# Pass the dispatch input through the workflow_call boundary
|
# Pass the dispatch input through the workflow_call boundary
|
||||||
# explicitly — a called workflow must not rely on reading the caller's
|
# explicitly — a called workflow must not rely on reading the caller's
|
||||||
|
|
@ -46,4 +46,4 @@ jobs:
|
||||||
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
|
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
|
||||||
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
|
# 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
|
# trigger) never count toward blocker:ci-red — a red trigger means "fix
|
||||||
# the caller", which no PR edit can do (#208 reads it).
|
# the caller", which no PR edit can do (#208 reads it).
|
||||||
|
|
@ -28,7 +28,7 @@ permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
jobs:
|
jobs:
|
||||||
labels:
|
labels:
|
||||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.6.1
|
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.6.3
|
||||||
# If the sweep caller below is named anything but labels-sweep.yml,
|
# If the sweep caller below is named anything but labels-sweep.yml,
|
||||||
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
|
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
|
||||||
# dogfood does (self-labels-sweep.yml).
|
# dogfood does (self-labels-sweep.yml).
|
||||||
|
|
@ -5,9 +5,11 @@
|
||||||
# Requirements:
|
# Requirements:
|
||||||
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
|
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
|
||||||
# your runner actually advertises (common: docker, ubuntu-latest).
|
# your runner actually advertises (common: docker, ubuntu-latest).
|
||||||
# - A repository/org secret RELEASE_TOKEN: a token with package:write and
|
# - A repository/org secret RELEASE_TOKEN: a token with read:user,
|
||||||
# repository:write scopes for an account allowed to publish packages
|
# write:package and write:repository scopes for an account allowed to
|
||||||
# under the heavy-duty org.
|
# publish packages under the heavy-duty org. read:user is required because
|
||||||
|
# publish-release.sh runs `stoke auth login`, which validates the token
|
||||||
|
# against /user.
|
||||||
|
|
||||||
name: release
|
name: release
|
||||||
on:
|
on:
|
||||||
|
|
@ -37,20 +39,5 @@ jobs:
|
||||||
|
|
||||||
- name: Create release and attach .deb
|
- name: Create release and attach .deb
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
TAG: ${{ github.ref_name }}
|
run: bash scripts/publish-release.sh "${{ github.ref_name }}" "$(node -p "require('./package.json').version")" "$(ls dist/stoke_*_all.deb)" "${{ github.repository_owner }}" stoke
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
DEB=$(ls dist/stoke_*_all.deb)
|
|
||||||
# Create the release if it does not exist yet, then grab its id.
|
|
||||||
RELEASE_ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
RELEASE_ID=$(curl -sf -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
|
|
||||||
"$API/releases" | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
|
|
||||||
fi
|
|
||||||
curl -sf -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$DEB" \
|
|
||||||
"$API/releases/$RELEASE_ID/assets?name=$(basename "$DEB")" >/dev/null
|
|
||||||
echo "Attached $(basename "$DEB") to release $TAG"
|
|
||||||
|
|
|
||||||
12
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
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
|
||||||
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
|
|
@ -4,13 +4,13 @@
|
||||||
- any-glob-to-any-file: ["src/**"]
|
- any-glob-to-any-file: ["src/**"]
|
||||||
"scope:packaging":
|
"scope:packaging":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml"]
|
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml", "package.json", "package-lock.json", "CHANGELOG.md", "changelog.d/**"]
|
||||||
"scope:manifests":
|
"scope:manifests":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["manifests/**"]
|
- any-glob-to-any-file: ["manifests/**"]
|
||||||
"scope:ci":
|
"scope:ci":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: [".forgejo/workflows/**"]
|
- any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"]
|
||||||
"scope:docs":
|
"scope:docs":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["README.md", "docs/**"]
|
- any-glob-to-any-file: ["*.md", "docs/**"]
|
||||||
|
|
|
||||||
6
.github/labels.conf
vendored
6
.github/labels.conf
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
||||||
triage-actors=claude-bot-andresmgsl
|
triage-actors=claude-bot-andresmgsl
|
||||||
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
|
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
|
||||||
scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path
|
scope:packaging|C5DEF5|scripts/, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces
|
||||||
scope:manifests|C5DEF5|manifests/ — the fleet repo registry data
|
scope:manifests|C5DEF5|manifests/ — the fleet repo registry data
|
||||||
scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates
|
scope:ci|C5DEF5|.forgejo/workflows/, .github/, and .ceremony/ — the test, label, and governance gates
|
||||||
scope:docs|C5DEF5|README and docs/ — the prose contract
|
scope:docs|C5DEF5|root Markdown and docs/ — the prose contract
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# AGENTS.md — start at .ceremony/
|
# AGENTS.md — start at .ceremony/
|
||||||
|
|
||||||
This repository is governed by
|
This repository is governed by
|
||||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
|
[heavy-duty/ceremony](https://forgejo.heavyduty.builders/heavy-duty/ceremony). Read
|
||||||
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||||
beside it. Repo specifics (the review panel roster, the scope labels, what
|
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||||
a drill means here, code conventions) live in CONTRIBUTING.md.
|
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||||
|
|
|
||||||
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.
|
||||||
101
README.md
101
README.md
|
|
@ -13,13 +13,38 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman
|
||||||
|
|
||||||
### With apt (Debian/Ubuntu — recommended)
|
### 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
|
```bash
|
||||||
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | 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
|
```bash
|
||||||
sudo install -d /etc/apt/keyrings
|
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.
|
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`.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"url": "https://forgejo.heavyduty.builders",
|
"url": "https://forgejo.heavyduty.builders",
|
||||||
"login": "kimi-reviewer-andresmgsl",
|
"login": "kimi-bot-andresmgsl",
|
||||||
"username": "kimi-reviewer-andresmgsl",
|
"username": "kimi-bot-andresmgsl",
|
||||||
"email": "andres+4@heavyduty.builders",
|
"email": "andres+4@heavyduty.builders",
|
||||||
"token": "<sha1>",
|
"token": "<sha1>",
|
||||||
"tokenId": 42
|
"tokenId": 42
|
||||||
|
|
@ -141,7 +187,7 @@ stoke auth login
|
||||||
Non-interactive example using environment variables:
|
Non-interactive example using environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export STOKE_USERNAME='kimi-reviewer-andresmgsl'
|
export STOKE_USERNAME='kimi-bot-andresmgsl'
|
||||||
export STOKE_PASSWORD='...'
|
export STOKE_PASSWORD='...'
|
||||||
stoke auth login
|
stoke auth login
|
||||||
```
|
```
|
||||||
|
|
@ -150,7 +196,7 @@ Password file example (avoids shell history and special-character issues):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod 600 /run/secrets/stoke-password
|
chmod 600 /run/secrets/stoke-password
|
||||||
stoke auth login -n kimi-reviewer-andresmgsl --password-file /run/secrets/stoke-password
|
stoke auth login -n kimi-bot-andresmgsl --password-file /run/secrets/stoke-password
|
||||||
```
|
```
|
||||||
|
|
||||||
Existing token example:
|
Existing token example:
|
||||||
|
|
@ -245,6 +291,31 @@ stoke repo clone -o heavy-duty -r stoke ~/src/stoke --depth 1
|
||||||
|
|
||||||
The stored token is handed to git ephemerally through environment-based config (`GIT_CONFIG_*`): it never appears in the remote URL, on the command line, or in the cloned repository's `.git/config`. Git's output is streamed directly and its exit status is forwarded, so failures behave exactly like a plain `git clone`.
|
The stored token is handed to git ephemerally through environment-based config (`GIT_CONFIG_*`): it never appears in the remote URL, on the command line, or in the cloned repository's `.git/config`. Git's output is streamed directly and its exit status is forwarded, so failures behave exactly like a plain `git clone`.
|
||||||
|
|
||||||
|
### `stoke repo sync`
|
||||||
|
|
||||||
|
Fast-forward an undiverged imported repository from an upstream Git URL.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
--from <upstream-url> upstream Git URL (required)
|
||||||
|
--branch <branch> branch to synchronize (default: the forge repository's default branch)
|
||||||
|
--tags also create new upstream tags
|
||||||
|
--dry-run report branch and tag moves without pushing
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke repo sync -o heavy-duty -r box \
|
||||||
|
--from https://github.com/heavy-duty/box.git
|
||||||
|
stoke repo sync -o heavy-duty -r box \
|
||||||
|
--from https://github.com/heavy-duty/box.git --tags --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
The command fetches both branch tips into an ephemeral bare repository and pushes only when the forge tip is an ancestor of the upstream tip. It refuses a diverged tree with both commit SHAs and never offers a force option. With `--tags`, new upstream tags are created; an existing forge tag that points elsewhere is reported and left untouched, and the command exits non-zero after applying any other safe moves. The stored Forgejo token uses the same environment-only Git authentication as `repo clone` and is never written to an argument, remote, or Git config.
|
||||||
|
|
||||||
|
This verb deliberately does not merge diverged trees, configure Forgejo pull-mirrors, or copy releases. Follow ceremony's live `docs/UPSTREAM-SYNC.md` procedure for a diverged tree; import a scheduled read-only repository as a pull-mirror; compose release mirroring from `release create` and `release upload`.
|
||||||
|
|
||||||
### `stoke repo create`
|
### `stoke repo create`
|
||||||
|
|
||||||
Create a new repository for the authenticated user or an organization.
|
Create a new repository for the authenticated user or an organization.
|
||||||
|
|
@ -401,7 +472,7 @@ Options:
|
||||||
Example used to move the heavy-duty repositories into the `heavy-duty` organization:
|
Example used to move the heavy-duty repositories into the `heavy-duty` organization:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stoke repo transfer -o kimi-reviewer-andresmgsl -r box --to heavy-duty
|
stoke repo transfer -o kimi-bot-andresmgsl -r box --to heavy-duty
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `POST /api/v1/repos/{owner}/{repo}/transfer`.
|
Calls `POST /api/v1/repos/{owner}/{repo}/transfer`.
|
||||||
|
|
@ -420,7 +491,7 @@ Options:
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stoke issue list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
stoke issue list -o kimi-bot-andresmgsl -r box -s all -l 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
|
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
|
||||||
|
|
@ -499,7 +570,7 @@ Options:
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stoke pr list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
stoke pr list -o kimi-bot-andresmgsl -r box -s all -l 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
|
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
|
||||||
|
|
@ -782,7 +853,7 @@ Options:
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stoke branch list -o kimi-reviewer-andresmgsl -r box
|
stoke branch list -o kimi-bot-andresmgsl -r box
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
|
Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
|
||||||
|
|
@ -802,8 +873,8 @@ Options:
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u andres --permission admin
|
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u andres --permission admin
|
||||||
stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u dan --permission admin
|
stoke collaborator add -o kimi-bot-andresmgsl -r infra -u dan --permission admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`.
|
Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`.
|
||||||
|
|
@ -939,7 +1010,7 @@ Options:
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stoke org team member-remove --team-id 1 -u kimi-reviewer-andresmgsl
|
stoke org team member-remove --team-id 1 -u kimi-bot-andresmgsl
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `DELETE /api/v1/teams/{id}/members/{username}`.
|
Calls `DELETE /api/v1/teams/{id}/members/{username}`.
|
||||||
|
|
@ -1061,7 +1132,7 @@ Release checklist:
|
||||||
|
|
||||||
## Verification: heavy-duty repository imports
|
## Verification: heavy-duty repository imports
|
||||||
|
|
||||||
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
|
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-bot-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`.
|
||||||
|
|
||||||
| Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
|
| Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
- `repo create --owner` can now create organization-owned repositories while preserving the authenticated-user default. (#24).
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
- Release commands can now stream asset uploads, rename single assets, report partial failures, print release ids, and list attached files. (#25).
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
- Adopted ceremony label and review automation with a machine-readable panel and scope taxonomy. (#30).
|
|
||||||
1
changelog.d/57.md
Normal file
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).
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "stoke",
|
"name": "stoke",
|
||||||
"version": "1.3.0",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "stoke",
|
"name": "stoke",
|
||||||
"version": "1.3.0",
|
"version": "1.5.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^15.0.0"
|
"commander": "^15.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "stoke",
|
"name": "stoke",
|
||||||
"version": "1.3.0",
|
"version": "1.5.0",
|
||||||
"description": "CLI for the heavy-duty forge (Forgejo)",
|
"description": "CLI for the heavy-duty forge (Forgejo)",
|
||||||
"main": "src/cli.js",
|
"main": "src/cli.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,9 @@ EOF
|
||||||
# Native package (no Debian revision in the version), so plain changelog.gz.
|
# Native package (no Debian revision in the version), so plain changelog.gz.
|
||||||
gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz"
|
gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz"
|
||||||
|
|
||||||
# Normalize permissions regardless of the builder's umask: no group/other
|
# Normalize permissions regardless of the builder's umask: traversable
|
||||||
# write anywhere, executable entry point.
|
# directories, readable files, and execute bits retained only where intended.
|
||||||
chmod -R go-w "$PKG/usr"
|
chmod -R u+rwX,go=rX "$PKG/usr"
|
||||||
chmod 0755 "$LIB/src/cli.js"
|
chmod 0755 "$LIB/src/cli.js"
|
||||||
|
|
||||||
# --- control -----------------------------------------------------------------
|
# --- control -----------------------------------------------------------------
|
||||||
|
|
|
||||||
73
scripts/changelog-section.sh
Executable file
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"
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
const UNSCOPED_PATHS = [
|
||||||
|
'.gitignore', // Repository plumbing has no product surface.
|
||||||
|
'assets/logo-mark.svg', // One legacy brand asset does not justify a scope taxonomy.
|
||||||
|
'test/*.test.js', // Tests inherit the scope of the production surface changed beside them.
|
||||||
|
];
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const options = {
|
const options = {
|
||||||
|
|
@ -55,6 +62,65 @@ function parseConfig(contents, filename) {
|
||||||
return { identities, scopes };
|
return { identities, scopes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLabeler(contents, filename) {
|
||||||
|
const mappings = new Map();
|
||||||
|
let current;
|
||||||
|
for (const line of contents.split(/\r?\n/)) {
|
||||||
|
const label = line.match(/^"([^"]+)":$/);
|
||||||
|
if (label) {
|
||||||
|
current = label[1];
|
||||||
|
if (mappings.has(current)) throw new Error(`duplicate scope mapping in ${filename}: ${current}`);
|
||||||
|
mappings.set(current, []);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const matcher = line.match(/^\s+- any-glob-to-any-file:\s*(\[[^\n]+\])$/);
|
||||||
|
if (!matcher) continue;
|
||||||
|
if (!current) throw new Error(`scope matcher has no label in ${filename}`);
|
||||||
|
let globs;
|
||||||
|
try {
|
||||||
|
globs = JSON.parse(matcher[1]);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(globs) || globs.length === 0 || globs.some((glob) => typeof glob !== 'string')) {
|
||||||
|
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
||||||
|
}
|
||||||
|
mappings.get(current).push(...globs);
|
||||||
|
}
|
||||||
|
for (const [label, globs] of mappings) {
|
||||||
|
if (globs.length === 0) throw new Error(`scope mapping has no globs in ${filename}: ${label}`);
|
||||||
|
}
|
||||||
|
return mappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScopeNames(scopes, mappings) {
|
||||||
|
const configNames = new Set(scopes.map((scope) => scope.split('|', 1)[0]));
|
||||||
|
const labelerNames = new Set(mappings.keys());
|
||||||
|
const onlyInConfig = [...configNames].filter((name) => !labelerNames.has(name)).sort();
|
||||||
|
const onlyInLabeler = [...labelerNames].filter((name) => !configNames.has(name)).sort();
|
||||||
|
const errors = [];
|
||||||
|
if (onlyInConfig.length > 0) errors.push(`scope names only in labels.conf: ${onlyInConfig.join(', ')}`);
|
||||||
|
if (onlyInLabeler.length > 0) errors.push(`scope names only in labeler.yml: ${onlyInLabeler.join(', ')}`);
|
||||||
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTrackedPaths(mappings) {
|
||||||
|
let tracked;
|
||||||
|
try {
|
||||||
|
tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`cannot enumerate tracked paths: ${error.message}`);
|
||||||
|
}
|
||||||
|
const globs = [...mappings.values()].flat();
|
||||||
|
const uncovered = tracked.filter((filename) => (
|
||||||
|
!UNSCOPED_PATHS.some((glob) => path.matchesGlob(filename, glob))
|
||||||
|
&& !globs.some((glob) => path.matchesGlob(filename, glob))
|
||||||
|
));
|
||||||
|
if (uncovered.length > 0) {
|
||||||
|
throw new Error(`tracked paths have no scope mapping: ${uncovered.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function validateIdentities(apiUrl, identities) {
|
async function validateIdentities(apiUrl, identities) {
|
||||||
for (const login of identities) {
|
for (const login of identities) {
|
||||||
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
||||||
|
|
@ -72,6 +138,10 @@ async function main() {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const contents = fs.readFileSync(options.config, 'utf8');
|
const contents = fs.readFileSync(options.config, 'utf8');
|
||||||
const { identities, scopes } = parseConfig(contents, options.config);
|
const { identities, scopes } = parseConfig(contents, options.config);
|
||||||
|
const labeler = path.join(path.dirname(options.config), 'labeler.yml');
|
||||||
|
const mappings = parseLabeler(fs.readFileSync(labeler, 'utf8'), labeler);
|
||||||
|
validateScopeNames(scopes, mappings);
|
||||||
|
validateTrackedPaths(mappings);
|
||||||
await validateIdentities(options.apiUrl, identities);
|
await validateIdentities(options.apiUrl, identities);
|
||||||
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/install-apt.sh
|
# ./scripts/install-apt.sh
|
||||||
# FORGE_URL=... OWNER=... ./scripts/install-apt.sh # non-default instance
|
# 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.
|
# 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}"
|
OWNER="${OWNER:-heavy-duty}"
|
||||||
DISTRIBUTION="${DISTRIBUTION:-stable}"
|
DISTRIBUTION="${DISTRIBUTION:-stable}"
|
||||||
COMPONENT="${COMPONENT:-main}"
|
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
|
# Where apt configuration lives; overridable so tests can run against a
|
||||||
# throwaway directory instead of the real /etc/apt.
|
# throwaway directory instead of the real /etc/apt.
|
||||||
APT_ETC="${STOKE_APT_ETC:-/etc/apt}"
|
APT_ETC="${STOKE_APT_ETC:-/etc/apt}"
|
||||||
|
|
||||||
KEYRING="$APT_ETC/keyrings/forgejo-$OWNER.asc"
|
KEYRING="$APT_ETC/keyrings/forgejo-$OWNER.asc"
|
||||||
LIST="$APT_ETC/sources.list.d/forgejo-$OWNER.list"
|
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=""
|
SUDO=""
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
|
@ -31,6 +47,18 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||||
SUDO="sudo"
|
SUDO="sudo"
|
||||||
fi
|
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() {
|
update_only_source() {
|
||||||
$SUDO apt-get update \
|
$SUDO apt-get update \
|
||||||
-o Dir::Etc::sourcelist="$1" \
|
-o Dir::Etc::sourcelist="$1" \
|
||||||
|
|
@ -80,8 +108,8 @@ ensure_nodejs_source() {
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Adding APT source for $FORGE_URL/$OWNER ..."
|
echo "Adding APT source for $FORGE_URL/$OWNER ..."
|
||||||
$SUDO install -d -m 0755 "$APT_ETC/keyrings"
|
$SUDO install -d -m 0755 "$APT_ETC/keyrings" "$APT_ETC/sources.list.d"
|
||||||
curl -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null
|
$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" \
|
echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
|
||||||
| $SUDO tee "$LIST" >/dev/null
|
| $SUDO tee "$LIST" >/dev/null
|
||||||
# tee inherits our umask; apt's unprivileged _apt user must be able to
|
# tee inherits our umask; apt's unprivileged _apt user must be able to
|
||||||
|
|
@ -94,7 +122,7 @@ $SUDO chmod 0644 "$KEYRING" "$LIST"
|
||||||
# is fatal; any other curl outcome (e.g. a network hiccup) is left for
|
# is fatal; any other curl outcome (e.g. a network hiccup) is left for
|
||||||
# apt-get update to report.
|
# apt-get update to report.
|
||||||
RELEASE_URL="$FORGE_URL/api/packages/$OWNER/debian/dists/$DISTRIBUTION/Release"
|
RELEASE_URL="$FORGE_URL/api/packages/$OWNER/debian/dists/$DISTRIBUTION/Release"
|
||||||
if [ "$(curl -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then
|
if [ "$($SUDO curl "${CURL_AUTH[@]}" -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then
|
||||||
echo "error: no stoke package has been published to the $OWNER Debian registry yet" >&2
|
echo "error: no stoke package has been published to the $OWNER Debian registry yet" >&2
|
||||||
echo "($RELEASE_URL returned 404)." >&2
|
echo "($RELEASE_URL returned 404)." >&2
|
||||||
echo "Install stoke via npm or manually instead — see the README." >&2
|
echo "Install stoke via npm or manually instead — see the README." >&2
|
||||||
|
|
@ -102,16 +130,37 @@ if [ "$(curl -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo
|
# Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo
|
||||||
# currently produces for its Debian registry (malformed Ed25519 MPI encoding
|
# currently produces for its Debian registry. The cause is recorded on #1; it
|
||||||
# in the upstream signing library). Try the properly signed source first so
|
# is not the key algorithm, because the registry serves an RSA-2048 signing
|
||||||
# this heals automatically once the forge is fixed; otherwise fall back to
|
# key (`gpg --list-packets` on repository.key reports `algo 1` with a
|
||||||
# [trusted=yes] — package integrity then relies on HTTPS to our own forge.
|
# 2048-bit pkey[0]). Try the properly signed source first so this heals
|
||||||
if ! update_only_source "$LIST"; then
|
# automatically once the forge is fixed. Only the exact live sqv failure, plus
|
||||||
echo
|
# the user's exact opt-in, permits an unverified source; auth, network, and
|
||||||
echo "WARNING: signature verification failed (known Forgejo registry issue" >&2
|
# other failures must leave verification enabled and retain apt's diagnostic.
|
||||||
echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2
|
if update_output="$(update_only_source "$LIST" 2>&1)"; then
|
||||||
echo "security is provided by HTTPS to $FORGE_URL." >&2
|
printf '%s\n' "$update_output"
|
||||||
echo
|
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" \
|
echo "deb [trusted=yes] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
|
||||||
| $SUDO tee "$LIST" >/dev/null
|
| $SUDO tee "$LIST" >/dev/null
|
||||||
$SUDO chmod 0644 "$LIST"
|
$SUDO chmod 0644 "$LIST"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
# component APT component, default: main
|
# component APT component, default: main
|
||||||
#
|
#
|
||||||
# Authentication (first match wins):
|
# Authentication (first match wins):
|
||||||
# 1. STOKE_TOKEN environment variable
|
# 1. STOKE_TOKEN environment variable (set from secrets.RELEASE_TOKEN in CI)
|
||||||
# 2. The token stored by `stoke auth login`
|
# 2. The token stored by `stoke auth login`
|
||||||
#
|
#
|
||||||
# The Forgejo URL defaults to the instance in the stoke config, falling back
|
# The Forgejo URL defaults to the instance in the stoke config, falling back
|
||||||
|
|
@ -30,13 +30,34 @@ CONFIG_JSON="$(node -e "const c = require('$ROOT/src/config').loadConfig(); if (
|
||||||
TOKEN="${STOKE_TOKEN:-$(node -pe "(JSON.parse(process.argv[1] || '{}').token) || ''" "$CONFIG_JSON")}"
|
TOKEN="${STOKE_TOKEN:-$(node -pe "(JSON.parse(process.argv[1] || '{}').token) || ''" "$CONFIG_JSON")}"
|
||||||
FORGE_URL="${FORGE_URL:-$(node -pe "(JSON.parse(process.argv[1] || '{}').url) || 'https://forgejo.heavyduty.builders'" "$CONFIG_JSON")}"
|
FORGE_URL="${FORGE_URL:-$(node -pe "(JSON.parse(process.argv[1] || '{}').url) || 'https://forgejo.heavyduty.builders'" "$CONFIG_JSON")}"
|
||||||
|
|
||||||
[ -n "$TOKEN" ] || { echo "error: no token. Set STOKE_TOKEN or run: stoke auth login" >&2; exit 1; }
|
if [ -z "$TOKEN" ]; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
error: no token.
|
||||||
|
In CI, this step reads STOKE_TOKEN from secrets.RELEASE_TOKEN; an empty value
|
||||||
|
means the secret is unset or unreadable by this workflow, not that the tool is missing.
|
||||||
|
Locally: export STOKE_TOKEN, or run `stoke auth login`.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${RUNNER_TEMP:-}" ]; then
|
||||||
|
TMP="$(mktemp -d "$RUNNER_TEMP/stoke-publish.XXXXXX")"
|
||||||
|
else
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
fi
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
HEADER_FILE="$TMP/authorization-header"
|
||||||
|
RESPONSE_FILE="$TMP/response"
|
||||||
|
umask 077
|
||||||
|
printf 'Authorization: token %s\n' "$TOKEN" >"$HEADER_FILE"
|
||||||
|
chmod 0600 "$HEADER_FILE"
|
||||||
|
|
||||||
URL="$FORGE_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
URL="$FORGE_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||||
echo "Uploading $(basename "$DEB") to $URL"
|
echo "Uploading $(basename "$DEB") to $URL"
|
||||||
|
|
||||||
STATUS="$(curl -sS -o /tmp/stoke-publish-response.$$ -w '%{http_code}' \
|
STATUS="$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
|
||||||
-X PUT -H "Authorization: token $TOKEN" \
|
-X PUT -H @"$HEADER_FILE" \
|
||||||
--upload-file "$DEB" "$URL")"
|
--upload-file "$DEB" "$URL")"
|
||||||
|
|
||||||
case "$STATUS" in
|
case "$STATUS" in
|
||||||
|
|
@ -44,9 +65,7 @@ case "$STATUS" in
|
||||||
409) echo "Already published (409): this exact version already exists in the registry." ;;
|
409) echo "Already published (409): this exact version already exists in the registry." ;;
|
||||||
*)
|
*)
|
||||||
echo "error: upload failed with HTTP $STATUS" >&2
|
echo "error: upload failed with HTTP $STATUS" >&2
|
||||||
cat /tmp/stoke-publish-response.$$ >&2 || true
|
cat "$RESPONSE_FILE" >&2 || true
|
||||||
rm -f /tmp/stoke-publish-response.$$
|
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
rm -f /tmp/stoke-publish-response.$$
|
|
||||||
|
|
|
||||||
49
scripts/publish-release.sh
Executable file
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
|
||||||
100
src/cli.js
100
src/cli.js
|
|
@ -8,6 +8,7 @@ const { execSync, spawnSync } = require('node:child_process');
|
||||||
const { stdin: input, stdout: output } = require('node:process');
|
const { stdin: input, stdout: output } = require('node:process');
|
||||||
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
|
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
|
||||||
const { ForgejoClient } = require('./api');
|
const { ForgejoClient } = require('./api');
|
||||||
|
const { syncRepository } = require('./repo-sync');
|
||||||
|
|
||||||
const pkg = require('../package.json');
|
const pkg = require('../package.json');
|
||||||
|
|
||||||
|
|
@ -294,6 +295,8 @@ auth
|
||||||
} else {
|
} else {
|
||||||
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
||||||
}
|
}
|
||||||
|
} else if (!config.tokenId && !options.localOnly) {
|
||||||
|
console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearConfig();
|
clearConfig();
|
||||||
|
|
@ -312,8 +315,12 @@ auth
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
if (!config || !config.token) {
|
if (!config || !config.token) {
|
||||||
console.log('Not authenticated.');
|
if (options.json) {
|
||||||
return;
|
console.log('{"authenticated": false}');
|
||||||
|
} else {
|
||||||
|
console.log('Not authenticated.');
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
|
@ -451,6 +458,49 @@ repo
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
repo
|
||||||
|
.command('sync')
|
||||||
|
.description('Fast-forward an imported repository from its upstream')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('--from <upstream-url>', 'upstream Git URL')
|
||||||
|
.option('--branch <branch>', 'branch to synchronize')
|
||||||
|
.option('--tags', 'also create new upstream tags', false)
|
||||||
|
.option('--dry-run', 'report changes without pushing', false)
|
||||||
|
.action((options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
if (!config || !config.url || !config.token) {
|
||||||
|
throw new Error('Not authenticated. Run: stoke auth login');
|
||||||
|
}
|
||||||
|
const base = config.url.replace(/\/+$/, '');
|
||||||
|
const forgeUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`;
|
||||||
|
const result = syncRepository({
|
||||||
|
forgeUrl,
|
||||||
|
upstreamUrl: options.from,
|
||||||
|
branch: options.branch,
|
||||||
|
includeTags: options.tags,
|
||||||
|
dryRun: options.dryRun,
|
||||||
|
env: gitAuthEnv({ ...config, url: base }),
|
||||||
|
});
|
||||||
|
if (result.changed) {
|
||||||
|
console.log(`${result.branch} ${result.oldSha}..${result.newSha}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${result.branch} is up to date at ${result.newSha}`);
|
||||||
|
}
|
||||||
|
for (const tag of result.newTags) {
|
||||||
|
console.log(`tag ${tag.name} create ${tag.sha}`);
|
||||||
|
}
|
||||||
|
for (const tag of result.movedTags) {
|
||||||
|
console.error(`tag ${tag.name} moved upstream: forge ${tag.forgeSha}, upstream ${tag.upstreamSha}; skipped`);
|
||||||
|
}
|
||||||
|
if (result.movedTags.length > 0) process.exitCode = 1;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Repository sync failed: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
repo
|
repo
|
||||||
.command('create')
|
.command('create')
|
||||||
.description('Create a new repository for the authenticated user or an organization')
|
.description('Create a new repository for the authenticated user or an organization')
|
||||||
|
|
@ -605,30 +655,30 @@ repo
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = item.service || 'github';
|
|
||||||
const isPrivate = item.public ? false : Boolean(item.private);
|
|
||||||
const payload = {
|
|
||||||
clone_addr: from,
|
|
||||||
repo_name: name,
|
|
||||||
repo_owner: item.owner || item.repo_owner || config.login,
|
|
||||||
service,
|
|
||||||
description: item.description || undefined,
|
|
||||||
private: isPrivate,
|
|
||||||
issues: normalizeBool(item.issues, true),
|
|
||||||
labels: normalizeBool(item.labels, true),
|
|
||||||
milestones: normalizeBool(item.milestones, true),
|
|
||||||
pull_requests: normalizeBool(item.pull_requests, true),
|
|
||||||
releases: normalizeBool(item.releases, true),
|
|
||||||
wiki: normalizeBool(item.wiki, true),
|
|
||||||
lfs: normalizeBool(item.lfs, false),
|
|
||||||
auth_token: resolveSourceToken(item.github_token, service),
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.keys(payload).forEach((key) => {
|
|
||||||
if (payload[key] === undefined) delete payload[key];
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const service = item.service || 'github';
|
||||||
|
const isPrivate = item.public ? false : Boolean(item.private);
|
||||||
|
const payload = {
|
||||||
|
clone_addr: from,
|
||||||
|
repo_name: name,
|
||||||
|
repo_owner: item.owner || item.repo_owner || config.login,
|
||||||
|
service,
|
||||||
|
description: item.description || undefined,
|
||||||
|
private: isPrivate,
|
||||||
|
issues: normalizeBool(item.issues, true),
|
||||||
|
labels: normalizeBool(item.labels, true),
|
||||||
|
milestones: normalizeBool(item.milestones, true),
|
||||||
|
pull_requests: normalizeBool(item.pull_requests, true),
|
||||||
|
releases: normalizeBool(item.releases, true),
|
||||||
|
wiki: normalizeBool(item.wiki, true),
|
||||||
|
lfs: normalizeBool(item.lfs, false),
|
||||||
|
auth_token: resolveSourceToken(item.github_token, service),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(payload).forEach((key) => {
|
||||||
|
if (payload[key] === undefined) delete payload[key];
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.migrateRepo(payload);
|
const result = await client.migrateRepo(payload);
|
||||||
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
|
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
|
||||||
results.push({ name, status: 'ok', url: result.html_url });
|
results.push({ name, status: 'ok', url: result.html_url });
|
||||||
|
|
|
||||||
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 };
|
||||||
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -40,10 +40,59 @@ test('global --config flag overrides the config location', () => {
|
||||||
// "Not authenticated" instead of silently using the default config.
|
// "Not authenticated" instead of silently using the default config.
|
||||||
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
|
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
|
||||||
const res = run(['--config', missing, 'auth', 'status']);
|
const res = run(['--config', missing, 'auth', 'status']);
|
||||||
assert.equal(res.status, 0);
|
assert.equal(res.status, 1);
|
||||||
assert.match(res.stdout, /Not authenticated/);
|
assert.match(res.stdout, /Not authenticated/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('auth status reports an absent session in text and JSON with a failing status', () => {
|
||||||
|
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}-auth-status.json`);
|
||||||
|
|
||||||
|
const text = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing });
|
||||||
|
assert.equal(text.status, 1);
|
||||||
|
assert.equal(text.stdout, 'Not authenticated.\n');
|
||||||
|
assert.equal(text.stderr, '');
|
||||||
|
|
||||||
|
const json = run(['auth', 'status', '--json'], { STOKE_CONFIG_FILE: missing });
|
||||||
|
assert.equal(json.status, 1);
|
||||||
|
assert.equal(json.stdout, '{"authenticated": false}\n');
|
||||||
|
assert.equal(json.stderr, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth logout identifies a supplied token that remains active without changing local-only output', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-auth-logout-'));
|
||||||
|
const cfg = path.join(dir, 'config.json');
|
||||||
|
const config = {
|
||||||
|
url: 'https://forge.test',
|
||||||
|
login: 'bot',
|
||||||
|
username: 'bot',
|
||||||
|
token: 'token-that-must-not-be-printed',
|
||||||
|
tokenId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify(config));
|
||||||
|
const logout = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg });
|
||||||
|
assert.equal(logout.status, 0, logout.stderr);
|
||||||
|
assert.match(logout.stdout, /local credentials/i);
|
||||||
|
assert.match(logout.stdout, /did not create this token/i);
|
||||||
|
assert.match(logout.stdout, /cannot revoke it/i);
|
||||||
|
assert.match(logout.stdout, /still valid on https:\/\/forge\.test/i);
|
||||||
|
assert.match(logout.stdout, /Settings > Applications/);
|
||||||
|
assert.doesNotMatch(logout.stdout, /Revoked token/);
|
||||||
|
assert.doesNotMatch(logout.stdout, /Password for/);
|
||||||
|
assert.doesNotMatch(logout.stdout, /token-that-must-not-be-printed/);
|
||||||
|
assert.equal(fs.existsSync(cfg), false);
|
||||||
|
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify(config));
|
||||||
|
const localOnly = run(['auth', 'logout', '--local-only'], { STOKE_CONFIG_FILE: cfg });
|
||||||
|
assert.equal(localOnly.status, 0, localOnly.stderr);
|
||||||
|
assert.equal(localOnly.stdout, 'Local credentials removed.\n');
|
||||||
|
assert.equal(fs.existsSync(cfg), false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('invalid --limit is rejected before any network call', () => {
|
test('invalid --limit is rejected before any network call', () => {
|
||||||
const res = run(['repo', 'list', '-l', 'abc']);
|
const res = run(['repo', 'list', '-l', 'abc']);
|
||||||
assert.equal(res.status, 1);
|
assert.equal(res.status, 1);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
|
|
@ -11,6 +12,11 @@ const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf');
|
||||||
const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
|
const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
|
||||||
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
|
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
|
||||||
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
|
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
|
||||||
|
const PACKAGE_MANIFEST = path.join(__dirname, '..', 'package.json');
|
||||||
|
const PACKAGE_LOCK = path.join(__dirname, '..', 'package-lock.json');
|
||||||
|
const CEREMONY_REPOSITORY = 'https://forgejo.heavyduty.builders/heavy-duty/ceremony';
|
||||||
|
const CEREMONY_VERSION = '0.6.3';
|
||||||
|
const CEREMONY_WORKFLOWS = ['labels.yml', 'labels-sweep.yml'];
|
||||||
const cleanups = [];
|
const cleanups = [];
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
|
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
|
@ -21,12 +27,30 @@ function writeConfig(contents) {
|
||||||
cleanups.push(dir);
|
cleanups.push(dir);
|
||||||
const config = path.join(dir, 'labels.conf');
|
const config = path.join(dir, 'labels.conf');
|
||||||
fs.writeFileSync(config, contents);
|
fs.writeFileSync(config, contents);
|
||||||
|
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runValidator(config, apiUrl) {
|
function writeRepository(configContents, labelerContents, files = {}) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-repository-'));
|
||||||
|
cleanups.push(dir);
|
||||||
|
fs.mkdirSync(path.join(dir, '.github'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, '.github', 'labels.conf'), configContents);
|
||||||
|
fs.writeFileSync(path.join(dir, '.github', 'labeler.yml'), labelerContents);
|
||||||
|
for (const [filename, contents] of Object.entries(files)) {
|
||||||
|
const target = path.join(dir, filename);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, contents);
|
||||||
|
}
|
||||||
|
execFileSync('git', ['init', '-q'], { cwd: dir });
|
||||||
|
execFileSync('git', ['add', '.'], { cwd: dir });
|
||||||
|
return { dir, config: path.join(dir, '.github', 'labels.conf') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runValidator(config, apiUrl, cwd = path.join(__dirname, '..')) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
||||||
|
cwd,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
|
@ -106,6 +130,71 @@ test('governance validator rejects malformed scope rows before identity requests
|
||||||
assert.doesNotMatch(result.stderr, /fetch failed/);
|
assert.doesNotMatch(result.stderr, /fetch failed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('governance validator rejects a tracked path outside every scope and the residue allowlist', async () => {
|
||||||
|
const config = [
|
||||||
|
'panel=codex-bot-andresmgsl',
|
||||||
|
'scope:cli|C5DEF5|src/ — command surface',
|
||||||
|
'scope:ci|C5DEF5|.github/ — governance surface',
|
||||||
|
].join('\n');
|
||||||
|
const labeler = [
|
||||||
|
'"scope:cli":',
|
||||||
|
' - changed-files:',
|
||||||
|
' - any-glob-to-any-file: ["src/**"]',
|
||||||
|
'"scope:ci":',
|
||||||
|
' - changed-files:',
|
||||||
|
' - any-glob-to-any-file: [".github/**"]',
|
||||||
|
].join('\n');
|
||||||
|
const repository = writeRepository(`${config}\n`, `${labeler}\n`, {
|
||||||
|
'src/covered.js': '',
|
||||||
|
'new-surface/uncovered.txt': '',
|
||||||
|
});
|
||||||
|
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
||||||
|
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /tracked paths have no scope mapping: new-surface\/uncovered\.txt/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('governance validator rejects scope names declared only in the labeler map', async () => {
|
||||||
|
const config = [
|
||||||
|
'panel=codex-bot-andresmgsl',
|
||||||
|
'scope:cli|C5DEF5|src/ — command surface',
|
||||||
|
].join('\n');
|
||||||
|
const labeler = [
|
||||||
|
'"scope:cli":',
|
||||||
|
' - changed-files:',
|
||||||
|
' - any-glob-to-any-file: ["src/**"]',
|
||||||
|
'"scope:extra":',
|
||||||
|
' - changed-files:',
|
||||||
|
' - any-glob-to-any-file: ["extra/**"]',
|
||||||
|
].join('\n');
|
||||||
|
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
|
||||||
|
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
||||||
|
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /scope names only in labeler\.yml: scope:extra/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('governance validator rejects scope names declared only in labels.conf', async () => {
|
||||||
|
const config = [
|
||||||
|
'panel=codex-bot-andresmgsl',
|
||||||
|
'scope:cli|C5DEF5|src/ — command surface',
|
||||||
|
'scope:renamed|C5DEF5|renamed/ — renamed surface',
|
||||||
|
].join('\n');
|
||||||
|
const labeler = [
|
||||||
|
'"scope:cli":',
|
||||||
|
' - changed-files:',
|
||||||
|
' - any-glob-to-any-file: ["src/**"]',
|
||||||
|
].join('\n');
|
||||||
|
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
|
||||||
|
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
||||||
|
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /scope names only in labels\.conf: scope:renamed/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('repository governance config resolves the current four-member panel and five scopes', async () => {
|
test('repository governance config resolves the current four-member panel and five scopes', async () => {
|
||||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||||
await withIdentityServer(logins, async (apiUrl) => {
|
await withIdentityServer(logins, async (apiUrl) => {
|
||||||
|
|
@ -115,30 +204,66 @@ test('repository governance config resolves the current four-member panel and fi
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('repository scope mapping covers every configured scope with the ruled paths', () => {
|
test('repository scope mapping covers every tracked path except the ruled residue', () => {
|
||||||
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
|
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
|
||||||
const expected = {
|
const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
|
||||||
'scope:cli': ['src/**'],
|
.flatMap((match) => JSON.parse(match[1]));
|
||||||
'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'],
|
const tracked = execFileSync('git', ['ls-files'], {
|
||||||
'scope:manifests': ['manifests/**'],
|
cwd: path.join(__dirname, '..'),
|
||||||
'scope:ci': ['.forgejo/workflows/**'],
|
encoding: 'utf8',
|
||||||
'scope:docs': ['README.md', 'docs/**'],
|
}).trim().split('\n');
|
||||||
};
|
const allowed = new Set([
|
||||||
|
'.gitignore',
|
||||||
|
'assets/logo-mark.svg',
|
||||||
|
...tracked.filter((filename) => /^test\/[^/]+\.test\.js$/.test(filename)),
|
||||||
|
]);
|
||||||
|
const uncovered = tracked.filter((filename) => (
|
||||||
|
!allowed.has(filename) && !globs.some((glob) => path.matchesGlob(filename, glob))
|
||||||
|
));
|
||||||
|
|
||||||
for (const [label, globs] of Object.entries(expected)) {
|
assert.deepEqual(uncovered, []);
|
||||||
assert.match(labeler, new RegExp(`^"${label}":`, 'm'), `${label} has no mapping`);
|
|
||||||
for (const glob of globs) assert.ok(labeler.includes(JSON.stringify(glob)), `${label} does not map ${glob}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('repository carries the complete 0.6.1 doctrine mirror and root router', () => {
|
test('package lock versions match the package manifest', () => {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(PACKAGE_MANIFEST, 'utf8'));
|
||||||
|
const lock = JSON.parse(fs.readFileSync(PACKAGE_LOCK, 'utf8'));
|
||||||
|
|
||||||
|
assert.equal(lock.version, manifest.version, 'package-lock.json version is stale');
|
||||||
|
assert.equal(lock.packages[''].version, manifest.version, 'package-lock.json root package version is stale');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repository carries the complete Forgejo 0.6.3 doctrine mirror and root router', () => {
|
||||||
const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
|
const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
|
||||||
for (const filename of vendored) {
|
for (const filename of vendored) {
|
||||||
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`);
|
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`);
|
||||||
}
|
}
|
||||||
const mirrorReadme = fs.readFileSync(path.join(REPOSITORY_MIRROR, 'README.md'), 'utf8');
|
const mirrorReadme = fs.readFileSync(path.join(REPOSITORY_MIRROR, 'README.md'), 'utf8');
|
||||||
|
const sourceVersionRecord = `[heavy-duty/ceremony](${CEREMONY_REPOSITORY}) at ${CEREMONY_VERSION}`;
|
||||||
|
assert.equal(
|
||||||
|
mirrorReadme.split(sourceVersionRecord).length - 1,
|
||||||
|
2,
|
||||||
|
'mirror README does not identify the exact Forgejo ceremony source and version in both records',
|
||||||
|
);
|
||||||
assert.match(mirrorReadme, /labels doctrine is vendored manually/);
|
assert.match(mirrorReadme, /labels doctrine is vendored manually/);
|
||||||
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
|
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
|
||||||
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/);
|
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/);
|
||||||
assert.match(fs.readFileSync(ROOT_AGENTS, 'utf8'), /read\s+`.ceremony\/AGENTS\.md` first/i);
|
const rootAgents = fs.readFileSync(ROOT_AGENTS, 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
rootAgents.includes(`[heavy-duty/ceremony](${CEREMONY_REPOSITORY})`),
|
||||||
|
'root router does not identify the Forgejo ceremony repository',
|
||||||
|
);
|
||||||
|
assert.match(rootAgents, /read\s+`.ceremony\/AGENTS\.md` first/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repository workflow pins use the exact Forgejo ceremony version', () => {
|
||||||
|
for (const workflow of CEREMONY_WORKFLOWS) {
|
||||||
|
const contents = fs.readFileSync(path.join(__dirname, '..', '.forgejo', 'workflows', workflow), 'utf8');
|
||||||
|
const prefix = `uses: heavy-duty/ceremony/.github/workflows/${workflow}@`;
|
||||||
|
const pins = contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith(prefix));
|
||||||
|
assert.deepEqual(
|
||||||
|
pins,
|
||||||
|
[`${prefix}${CEREMONY_VERSION}`],
|
||||||
|
`${workflow} does not pin ceremony ${CEREMONY_VERSION}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
248
test/import-batch.test.js
Normal file
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -13,12 +13,16 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh');
|
||||||
// candAfterUpdate Candidate after any `apt-get update`
|
// candAfterUpdate Candidate after any `apt-get update`
|
||||||
// candAfterNodesource Candidate after an update once nodesource.list exists
|
// candAfterNodesource Candidate after an update once nodesource.list exists
|
||||||
// releaseStatus HTTP status curl reports for the registry Release file
|
// 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,
|
// 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.
|
// so every scenario doubles as a regression test for locale-safe parsing.
|
||||||
const cleanups = [];
|
const cleanups = [];
|
||||||
process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); });
|
process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus }) {
|
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified, precreateSourcesDir = true }) {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-'));
|
||||||
cleanups.push(root);
|
cleanups.push(root);
|
||||||
const bin = path.join(root, 'bin');
|
const bin = path.join(root, 'bin');
|
||||||
|
|
@ -26,7 +30,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
||||||
const aptEtc = path.join(root, 'etc', 'apt');
|
const aptEtc = path.join(root, 'etc', 'apt');
|
||||||
fs.mkdirSync(bin, { recursive: true });
|
fs.mkdirSync(bin, { recursive: true });
|
||||||
fs.mkdirSync(state, { 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);
|
fs.writeFileSync(path.join(state, 'candidate'), candInitial);
|
||||||
if (preexistingNodesourceList !== undefined) {
|
if (preexistingNodesourceList !== undefined) {
|
||||||
fs.writeFileSync(path.join(aptEtc, 'sources.list.d', 'nodesource.list'), preexistingNodesourceList);
|
fs.writeFileSync(path.join(aptEtc, 'sources.list.d', 'nodesource.list'), preexistingNodesourceList);
|
||||||
|
|
@ -39,10 +43,30 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
||||||
|
|
||||||
// Force the non-root path so every mutation goes through the sudo stub.
|
// Force the non-root path so every mutation goes through the sudo stub.
|
||||||
stub('id', 'echo 1000');
|
stub('id', 'echo 1000');
|
||||||
stub('sudo', 'exec "$@"');
|
stub('sudo', 'SUDO_ACTIVE=1 exec "$@"');
|
||||||
|
stub('tee', [
|
||||||
|
'for destination in "$@"; do',
|
||||||
|
' case "$destination" in',
|
||||||
|
' */auth.conf.d/forgejo-*.conf)',
|
||||||
|
' mode="$(stat -c %a "$destination" 2>/dev/null || true)"',
|
||||||
|
' if [ "$mode" != 600 ]; then',
|
||||||
|
' echo "credential destination was not mode 0600 before write" >&2',
|
||||||
|
' exit 78',
|
||||||
|
' fi',
|
||||||
|
' ;;',
|
||||||
|
' esac',
|
||||||
|
'done',
|
||||||
|
'exec /usr/bin/tee "$@"',
|
||||||
|
].join('\n'));
|
||||||
// Registry Release-file probes (URLs under /dists/) answer with the
|
// Registry Release-file probes (URLs under /dists/) answer with the
|
||||||
// scenario's HTTP status; everything else is a key fetch.
|
// scenario's HTTP status; everything else is a key fetch.
|
||||||
stub('curl', [
|
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',
|
'for a in "$@"; do',
|
||||||
' case "$a" in */dists/*) echo "${RELEASE_STATUS:-200}"; exit 0;; esac',
|
' case "$a" in */dists/*) echo "${RELEASE_STATUS:-200}"; exit 0;; esac',
|
||||||
'done',
|
'done',
|
||||||
|
|
@ -58,8 +82,16 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
stub('apt-get', [
|
stub('apt-get', [
|
||||||
'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"',
|
'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',
|
'for a in "$@"; do',
|
||||||
' if [ "$a" = update ]; then',
|
' 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',
|
' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then',
|
||||||
' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"',
|
' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"',
|
||||||
' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then',
|
' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then',
|
||||||
|
|
@ -82,6 +114,10 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
||||||
CAND_AFTER_UPDATE: candAfterUpdate || '',
|
CAND_AFTER_UPDATE: candAfterUpdate || '',
|
||||||
CAND_AFTER_NODESOURCE: candAfterNodesource || '',
|
CAND_AFTER_NODESOURCE: candAfterNodesource || '',
|
||||||
RELEASE_STATUS: releaseStatus || '',
|
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
|
LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -96,6 +132,9 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
||||||
nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
|
nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
|
||||||
nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
|
nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
|
||||||
forgeKeyMode: mode(path.join(aptEtc, 'keyrings', 'forgejo-heavy-duty.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')) || '',
|
aptGetLog: read(path.join(state, 'apt-get.log')) || '',
|
||||||
};
|
};
|
||||||
// Drop the throwaway tree after we have read everything we need.
|
// Drop the throwaway tree after we have read everything we need.
|
||||||
|
|
@ -171,3 +210,118 @@ test('registry Release file present: proceeds with the install', () => {
|
||||||
assert.equal(s.res.status, 0, s.res.stderr);
|
assert.equal(s.res.status, 0, s.res.stderr);
|
||||||
assert.match(s.aptGetLog, /install -y stoke/);
|
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