Compare commits

..

No commits in common. "main" and "build/240-exhaustive-timeline" have entirely different histories.

46 changed files with 303 additions and 1335 deletions

19
.github/DISCUSSION_TEMPLATE/ideas.yml vendored Normal file
View file

@ -0,0 +1,19 @@
# Light on purpose: discussions are where ambiguity is ALLOWED — a form
# that demands rigor at the door defeats the room's purpose (issue #24,
# decision 4). If these prompts fight the flow in practice, delete them
# before adding fields to them.
body:
- type: textarea
attributes:
label: What's the itch?
description: >-
Vague is fine — a bug, an idea, a "we should…". Triage turns this
into work (or an answer); you don't have to.
validations:
required: false
- type: textarea
attributes:
label: What would "done" feel like?
description: Optional — a sketch of the outcome, if you can already see it.
validations:
required: false

15
.github/DISCUSSION_TEMPLATE/q-a.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# Light on purpose — same reasoning as ideas.yml (issue #24, decision 4):
# optional prompts only, delete before hardening.
body:
- type: textarea
attributes:
label: What's the question?
description: Ask it plainly — context and links help, none are required.
validations:
required: false
- type: textarea
attributes:
label: What have you tried or read already?
description: Optional — saves the answerer a lap.
validations:
required: false

View file

@ -1,12 +1,13 @@
# 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.
# in this org. With blank issues disabled and exactly one form, a "New
# issue" click shows the discussion link first; that auto-suggest is native
# GitHub behavior, no automation needed.
blank_issues_enabled: false
contact_links:
- name: Ideas, bugs, questions — file a Proposal
url: https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/new?template=proposal.yml
- name: Ideas, bugs, questions — start a Discussion
url: https://github.com/heavy-duty/ceremony/discussions
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).
Humans (and agents) never file issues here — discussions are where
intent lives. Triage converges every discussion to an outcome, and
only triage mints issues (TRIAGE.md).

View file

@ -1,34 +0,0 @@
# 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

View file

@ -6,8 +6,8 @@
# 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 issue contract (TRIAGE.md) as a form. Only triage mints issues —
everyone else starts a Discussion. Triage may still compose by hand when
the form fights it: `gh issue create --body-file` bypasses forms and stays
legitimate for the triage identity.
body:
@ -25,7 +25,7 @@ body:
attributes:
label: Context
description: >-
Why this exists, with links — the proposal it came from, the code
Why this exists, with links — the discussion it came from, the code
it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
validations:

1
.github/labeler.yml vendored
View file

@ -46,7 +46,6 @@ scope:release-flow:
- CHANGELOG.md
- drills/**
- test/decide.test.sh
- test/preflight.test.sh
- test/facts.test.sh
- test/release-chain.test.sh
- test/version.test.sh

View file

@ -19,7 +19,6 @@ printf '%s\n' \
bin/ \
lib/version.sh \
lib/decide.sh \
lib/preflight.sh \
lib/facts.sh \
lib/changelog.sh \
lib/forge.sh

View file

@ -3,11 +3,9 @@ name: labels-sweep
# jobs that rode labels.yml until #209. Triggers and permissions live in the
# caller; docs/CONSUMERS.md carries the complete caller stub
# (workflow_dispatch plus the hourly cron, which relocated here with the
# sweep). Issue events and same-repository PR events still yield a sweep within
# seconds: labels.yml's trigger job dispatches this workflow's caller on those
# events. Fork-headed PR events carry a read-only token on this Forgejo, so
# state, blocker, and handoff reconciliation waits for the caller's scheduled
# cadence; the sweep does not apply path-derived scope labels (#241).
# sweep). Board events still yield a sweep within seconds: labels.yml's
# trigger job dispatches this workflow's caller on every event it used to
# run reconcile on.
#
# Detached on purpose (#209): every sweep covers every open PR and all
# sweeps serialize through ONE shared concurrency group, so GitHub's
@ -26,8 +24,7 @@ name: labels-sweep
# taxonomy (its `bootstrap` input defaults to "yes"), exactly what
# dispatching the labels caller did before the split. The trigger job's
# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty
# for every issue and same-repository PR wake, the same reason cron runs never
# bootstrapped.
# for every board event, the same reason cron runs never bootstrapped.
#
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
# create workflow runs from GITHUB_TOKEN-raised events (the trigger's
@ -66,7 +63,7 @@ on:
env:
# A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.6.3"
CEREMONY_SELF_REF: "0.6.2"
jobs:
reconcile:

View file

@ -2,16 +2,12 @@ name: labels
# Reusable half of the labels automation. Triggers and permissions live in
# the caller; docs/CONSUMERS.md carries the complete caller stub.
#
# The caller uses pull_request_target, not pull_request, so same-repository PRs
# keep the base repository's write token without running PR code. On this
# Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
# token. Those runs therefore attempt no writes. The scheduled sweep later
# reconciles state, blockers, and handoff, but it does not apply path-derived
# scope labels; consumers that require those labels on fork heads apply them
# manually. The explicit fork_head job below records that disposition as a
# successful check. Both write paths execute only for same-repository heads.
# Scope reads changed paths and the path mapping through the API and checks out
# only the ceremony implementation. Keep it that way (#241).
# The caller uses pull_request_target, not pull_request: every PR in this
# family arrives from a fork, where pull_request runs with a READ-ONLY token
# and cannot label anything. _target is safe in this workflow because no PR
# code is ever checked out or executed — scope reads changed paths and the
# path mapping via the API and checks out only the ceremony implementation.
# Keep it that way.
#
# The reconcile sweep lived here until #209. Riding the PR-triggered run
# meant every displacement in the sweep's shared concurrency queue recorded
@ -23,14 +19,10 @@ name: labels
# one `review_requested` event per panelist per request, so every review
# round displaces runs and the rate scales with panel size. The
# sweep now lives in labels-sweep.yml behind its own caller, and the
# trigger job below is its instant wake: it fires on every issue event and
# same-repository PR event this caller subscribes to, preserving that part of
# the surface that used to run reconcile directly. Same-repository wake latency
# (#137) remains seconds-scale, while a displaced sweep cancels on the Actions
# tab, attached to no PR. Fork-headed runs cannot dispatch with their read-only
# token, so state, blocker, and handoff reconciliation waits for the scheduled
# sweep; path-derived scope labels are not applied to fork heads. PR checks show
# scope + trigger for same-repository heads, or fork_head for fork heads.
# trigger job below is its wake: it fires on every event this caller
# subscribes — the exact surface that used to run reconcile directly — so
# the wake latency (#137) is unchanged, while a displaced sweep cancels on
# the Actions tab, attached to no PR. PR checks show scope + trigger only.
#
# This cannot loop: the trigger's dispatch and the reconciler's label
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
@ -56,7 +48,7 @@ on:
env:
# A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.6.3"
CEREMONY_SELF_REF: "0.6.2"
jobs:
scope:
@ -68,7 +60,6 @@ jobs:
# scope run is clobbered.
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'labeled' &&
github.event.action != 'unlabeled' &&
github.event.action != 'review_requested' &&
@ -115,12 +106,15 @@ jobs:
CONFIG_REF: ${{ github.sha }}
trigger:
# The sweep's instant wake (#209) keeps the whole non-PR event surface and
# same-repository PRs. Fork-headed PRs are excluded because this Forgejo
# gives their pull_request_target run a read-only token (#241); fork_head
# records which reconciliation waits for the sweep and that path-derived
# scope labels are not applied there. Non-PR events include workflow_dispatch:
# excluding it would make a dispatched labels caller silently do nothing.
# The sweep's wake (#209). No `if:`: reconcile carried none, so the
# trigger keeps the whole event surface the caller subscribes —
# workflow_dispatch of the labels caller itself included. That cannot
# double-fire bootstrap: this dispatch always carries bootstrap=no, so
# a dispatched labels caller yields one plain sweep, and the taxonomy
# bootstrap fires solely on a manual dispatch of the sweep caller
# (whose input defaults to "yes"). Excluding workflow_dispatch here
# would instead make a dispatched labels caller do nothing at all —
# a silent no-op run is worse than a redundant sweep.
#
# LOUD on failure — never `|| true`: a red trigger is the
# misconfiguration alarm. A consumer that bumps the pin without adding
@ -128,9 +122,6 @@ jobs:
# `bootstrap` input (unexpected input), or without `actions: write`
# on this caller (permission denied) fails HERE, visibly on the PR,
# instead of silently never sweeping again.
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: dispatch the sweep
@ -208,16 +199,3 @@ jobs:
exit 1
fi
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
fork_head:
# This Forgejo keeps pull_request_target read-only for fork heads (#241),
# so name the deliberately unsupported scope write as well as the deferred
# state machine instead of letting a green no-op promise full labelling.
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: explain deferred fork labels
run: >-
echo "labels: fork head has a read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads"

View file

@ -129,7 +129,7 @@ env:
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
# shared logic arrives as script files via checkout, not as inner `uses:`
# references.
CEREMONY_SELF_REF: "0.6.3"
CEREMONY_SELF_REF: "0.6.2"
VERSION_SOURCE: ${{ inputs.version-source }}
jobs:
@ -205,40 +205,31 @@ jobs:
fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: preflight — resume this merge, refuse every other collision
id: preflight
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
MERGE_SHA: ${{ github.sha }}
# The pure table in lib/preflight.sh distinguishes a stranded run of
# this door from a completed release or a tag at another commit (#273).
# What makes a re-run of a completed ceremony refuse instead of
# clobber, and what catches a manual tag racing the merge.
run: |
tag_read_rc=0
tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$?
case "$tag_read_rc" in
0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;;
2) tag_shas="" ;;
*)
echo "could not read tag '$VER' from origin (git ls-remote exit $tag_read_rc) — refusing rather than assuming it does not exist." >&2
exit 1
;;
esac
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! released="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
exit 1
fi
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/preflight.sh"
out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)"
printf '%s\n' "$out"
printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT"
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: tag the merge commit — same job as the publish, on purpose
if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes'
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
@ -372,22 +363,6 @@ jobs:
fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: no published release may exist
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: artifact hook — the consumer's own release-artifact action
# After the tag exists (it fired this door), before the publish —
# the same contract as the merge door's twin step.

View file

@ -7,16 +7,13 @@ name: labels-sweep
on:
# The consumer owns this cadence (#203). Hourly is the recommended default
# when no other engine drives board state: the cron is then the sweep's ONLY
# wake for a review verdict landing (there is no
# wake for four transition classes — a review verdict landing (there is no
# pull_request_review trigger on the labels caller), blocker:ci-red set or
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
# ANOTHER PR merges under this one, and the time-based stale / 48h
# claim-reclaim, plus every state, blocker, and handoff transition for a
# fork-headed PR on this Forgejo because its pull_request_target token is
# read-only (#241). The sweep never applies path-derived scope labels. Issue
# events and same-repository PR events carry the rest in seconds, one
# trigger-job dispatch away. Hourly trades ≤1h of latency on the scheduled
# classes while cutting nominal scheduled sweeps from four an hour to one at
# claim-reclaim. The labels caller's events carry the rest in seconds, one
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four
# while cutting nominal scheduled sweeps from four an hour to one at
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
@ -25,9 +22,8 @@ on:
# A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
# caller did before #209. The reusable's trigger job wakes this workflow
# with bootstrap=no on every issue and same-repository PR event — an
# event-woken sweep must not re-upsert ~20 labels each time — so declaring
# this input is part of the
# with bootstrap=no on every board event — an event-woken sweep must not
# re-upsert ~20 labels each time — so declaring this input is part of the
# caller contract: a dispatch naming an undeclared input is refused, and
# the trigger job goes loudly red.
workflow_dispatch:

View file

@ -8,12 +8,8 @@ name: labels
# Since #209 this caller carries the PR/issue event surface only. The
# reconcile sweep no longer rides these runs — the reusable's trigger job
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
# hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
# same-repository PR events below still yield a sweep within seconds, one
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
# so their successful labels run leaves state, blocker, and handoff
# reconciliation to the hourly sweep; path-derived scope labels are not
# applied to those heads (#241).
# hourly cron and the manual/bootstrap workflow_dispatch. A board event
# below still yields a sweep within seconds, one dispatch hop later.
on:
# Narrowed (#199) to the actions that carry a queue-state change the hourly
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
@ -30,16 +26,14 @@ on:
issues:
types: [opened, closed, edited, reopened]
pull_request_target:
# These carry the head/draft/review facts the sweep derives state:* from.
# Same-repository heads wake that sweep in seconds; fork heads cannot write
# with this Forgejo's read-only token, so state, blocker, and handoff
# reconciliation waits for the scheduled cadence. The sweep does not apply
# path-derived scope labels to those heads (#241).
# labeled/unlabeled are the same-repository handoff wake — the author's
# optimistic state:needs-human write, confirmed or corrected here in
# seconds (#11); synchronize re-derives on every push; review_requested/
# review_request_removed clear (or restore) blocker:unrequested on that
# same instant path (#137).
# Every PR arrives from a fork, so these carry the head/draft/review facts
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
# the author's optimistic state:needs-human write, confirmed or corrected
# here in seconds (#11); synchronize re-derives on every push;
# review_requested/review_request_removed wake the sweep that clears (or
# restores) blocker:unrequested — without them the one event that makes
# the label false could not clear it, and a quiet repo wore the red flag
# until the advisory cron (#137).
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
permissions:
contents: read

View file

@ -14,7 +14,7 @@ reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line |
|---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
@ -23,7 +23,7 @@ are the shared state machine, and misusing one lies to every other agent on
the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process proposals or the
asked to implement an issue → builder; asked to process discussions or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on.
@ -31,13 +31,13 @@ pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints work issues.** Found work? File or extend a proposal.
- **Only triage mints issues.** Found work? Open or extend a discussion.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button.

View file

@ -140,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
(#216).
- **Scope discipline: the PR does the issue — whole, and nothing else.**
Adjacent problems go to a proposal, or a comment on the relevant issue;
you do not mint work issues — nobody but triage does — and you do not fix
Adjacent problems go to a discussion, or a comment on the relevant issue;
you do not mint issues — nobody but triage does — and you do not fix
drive-by findings in the same PR.
## The review round

View file

@ -22,23 +22,6 @@ were adopted by port rather than merge. Upstream's separate `0.4.1` section
is deliberately not carried — the tag published here is the one this section
is the body of. Each sync updates this line (docs/UPSTREAM-SYNC.md, #200).
## 0.6.3 — 2026-08-26
### Changed
- The shipped 0.6.2 changelog section now carries #238's entry, which its release PR's merge base could not see; the published 0.6.2 release body is left as tagged, so tree and publication differ by that one line (#238, #231).
- Replace the unavailable intake rule with a proposal form that triage converts into work or refuses with reasons (#247).
### Fixed
- The `needs-triage` label now directs untriaged issues toward work normalization or a reasoned refusal instead of an unavailable discussion path (#265).
- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).
- Kept drill doctrine's release-path instructions aligned with the executable manifest by removing its duplicate path list (#251).
- Preserve Forgejo workflow names in status rollups so the label reconciler excludes only its own checks. (#243).
- Fork-headed label runs stay green without attempting forbidden writes, while same-repository heads keep instant scope and reconciliation wakes (#241).
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).
- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234).
## 0.6.2 — 2026-08-24
### Changed
@ -73,7 +56,6 @@ is the body of. Each sync updates this line (docs/UPSTREAM-SYNC.md, #200).
### Fixed
- Review-round state now reads each forge's live review-request set directly, so stale Forgejo approvals no longer hand an in-progress fix round back to the panel (#238).
- Forgejo drafts and fast-forward conflict-check windows no longer surface as merge conflicts when the API has not distinguished one (#236).
- Forgejo review requests no longer count as verdicts, while its blocking and comment states now grade like their GitHub equivalents (#235).

View file

@ -10,22 +10,22 @@ the two is a bug.
Work moves through one pipeline, and every stage has an owner:
```
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
- **Proposals are where intent lives.** Anyone — human or agent — who has an
idea, a bug, a question, or a "we should…" files a **proposal**, not a work
issue. Proposals are allowed to be vague; that is what they are for.
- **Work issues are minted only by triage.** Nobody else writes work issues —
not humans, not builders, not reviewers. A work issue is a work order with a quality
- **Discussions are where intent lives.** Anyone — human or agent — who has an
idea, a bug, a question, or a "we should…" opens a **discussion**, not an
issue. Discussions are allowed to be vague; that is what they are for.
- **Issues are minted only by triage.** Nobody else writes issues — not
humans, not builders, not reviewers. An issue is a work order with a quality
bar (the issue contract in [TRIAGE.md](TRIAGE.md)), and the bar holds
because exactly one role is accountable for it. An issue that appears
through any other door gets `needs-triage` and is normalized or converted
back into a proposal.
back into a discussion.
- **Builders turn one issue into one PR.** [BUILDER.md](BUILDER.md).
- **Reviewers converge on a verdict.** [REVIEWER.md](REVIEWER.md).
- **Humans decide twice**: in the proposal (what is worth doing, and any
- **Humans decide twice**: in the discussion (what is worth doing, and any
call triage escalates back) and at the merge (whether it ships). Everything
between those two points is agent work by default.
- **Merging a release PR ships it** — the release ceremony this repo's
@ -48,7 +48,7 @@ the box is the blast-radius boundary; roles are what a session is told, and
| identity | box (rig tenant) | standing work |
|---|---|---|
| `claude-bot-andresmgsl` | `triage` (claude-box) | **triage** — the only door work issues come through; this identity mints work issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
| `claude-bot-andresmgsl` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
| `codex-bot-andresmgsl` | codex-box | build + review |
| `glm-bot-andresmgsl` | glm-box | review |
| `kimi-bot-andresmgsl` | kimi-box | review |
@ -62,7 +62,7 @@ panel-minus-author, and three is what it currently comes to, not a second
rule. Builders and triage default to different models so the issue contract
is honestly exercised — a spec gap should surface as a question on the
issue, not be silently filled by shared priors. Humans (`andres`) decide in
proposals and merge; the roster is config, not doctrine — swapping a
discussions and merge; the roster is config, not doctrine — swapping a
vendor is an edit to this table (and to `panel=` in
`.github/labels.conf` once #10 lands), nothing more.
@ -143,8 +143,7 @@ A governed repo (box, rig, cast, incubator, …) therefore carries:
- the **`scope:*` label set** (`.github/labels.conf` + `.github/labeler.yml`),
- the **drill meaning** (`drills/README.md`),
- the repo's own code conventions;
- **An intake door is open**: install the proposal form and `needs-triage`
flow, or use a forge-native intake surface.
- **Discussions enabled**, so the triage door exists.
One pin governs both the machinery and the doctrine: the ref a repo's
workflows call is the ref its `.ceremony/` mirror is verified against.

View file

@ -136,7 +136,7 @@ The earlier form of this file folded handoff and rebase into the other
builder wakes; they are duties of their own.
- **Triage signals**, per registry repo: `needs-triage` issues,
queue-unlabeled strays, proposals without triage's voice, unread
queue-unlabeled strays, discussions without triage's voice, unread
`@`-mentions (their own session), and `blocked` issues whose named blockers
have all landed — a lead the session verifies, never a label the engine
flips. Backlog hygiene (stale claims, label invariants) runs hourly,

View file

@ -27,10 +27,8 @@ and the reconciler recomputes it from GitHub's own facts.
`state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one
hand-set state). On a same-repository head, the `labeled` event fires the
sweep that validates the write within seconds; on a fork head whose
`pull_request_target` token is read-only, validation waits for the scheduled
sweep cadence (#241).
hand-set state); the `labeled` event fires the sweep that validates the
write within seconds.
## PR blockers — what is in the way? (facts, as many as apply)
@ -51,7 +49,7 @@ strips it on sight).
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
| `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 |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
@ -242,4 +240,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
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.
deleted at bootstrap — a `question` is a discussion, not an issue.

View file

@ -108,8 +108,8 @@ workflow that carries it.
consumer's release, when it is stale.)
**The merge is the ship decision; the tag is transcription.** After the
merge, [release.yml](.github/workflows/release.yml#L136-L310) asserts its
way to certainty, tags the merge commit, publishes the forge release with
merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its
way to certainty, tags the merge commit, publishes the GitHub release with
the version's own changelog section as the body — the curated prose, never
the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
canonical extractor, and [bin/changelog-section](bin/changelog-section) is
@ -127,13 +127,12 @@ steps run past the tag, and what a failure at each leaves behind is what
sorts them. Two fail before the release exists: the consumer's
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
the publish, so its non-zero exit aborts, and the publish itself
([`forge_release_create`](.github/workflows/release.yml#L264-L277))
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258))
can fail on the API call or the assets. Either leaves the same state — a tag
standing and no release — which the merge-door preflight recognizes and a
re-run resumes. The tag door remains the fallback when the original run is no
longer reachable or the release must come from a fixed tree. The third is the
re-arm, which runs after the publish, and its refusal is the single failure in
this file that leaves a real release behind.
standing and no release — which the
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag
door recovers. The third is the re-arm, which runs after the publish, and its
refusal is the single failure in this file that leaves a real release behind.
## The two doors
@ -142,20 +141,18 @@ this file that leaves a real release behind.
`release`-labeled PR whose version transitioned to bare is the ceremony,
everything legitimate that isn't one is a green no-op, and every
half-ceremony dies loudly
([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every
([release.yml](.github/workflows/release.yml#L136-L301)). Use it for every
normal release.
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push
**no `v` prefix**, box's 0.6.0 set the scheme
([release.yml](.github/workflows/release.yml#L325-L410)) — publishes the
([release.yml](.github/workflows/release.yml#L303-L371)) — publishes the
same way. The tag is the operator's explicit act, so there is no decide
and no label check — what is left is three asserts: **the tag names the
and no label check — what is left is two asserts: **the tag names the
tree's own version**
([L350L361](.github/workflows/release.yml#L350-L361)), **the tagged
([L328L339](.github/workflows/release.yml#L328-L339)) and **the tagged
tree carries a publishable `## X.Y.Z` section**
([L362L374](.github/workflows/release.yml#L362-L374)), and **no published
release already exists for the tag**
([L375L390](.github/workflows/release.yml#L375-L390)); any failure
([L340L352](.github/workflows/release.yml#L340-L352)); either failing
refuses, creating nothing. No `-dev` bump either
— the fallback does not rewrite main (cast's precedent). Use it when the
merge path is red, for backfills, and for the
@ -473,7 +470,7 @@ so this line can only appear when some *other* caller invokes `version_read`
directly with a backend that is neither `file` nor `package-json`. Fix that
caller.
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L310))
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301))
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
@ -483,21 +480,22 @@ without its stamp (a state the
the PR — red main here means it was overridden). Stamp the section on main,
then publish by the tag door.
> release '$VER' already exists — this release already happened; refusing to re-release, creating nothing.
> tag '$VER' already exists at <tag sha> but this run would tag <MERGE_SHA> — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit.
> NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run.
> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
> release '$VER' already exists — refusing to re-release, creating nothing.
[L208L239](.github/workflows/release.yml#L208-L239), the merge-door
preflight — the published-release refusal prevents clobbering, the
different-commit refusal diagnoses a racing or manual tag with both SHAs, and
the notice resumes this door after its tag succeeded but the artifact hook or
publish failed. Re-run the merge-door job first. If that run is no longer
reachable or the tree itself needs repair, use the tag-door fallback: delete
and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
[L208L223](.github/workflows/release.yml#L208-L223), the nothing-exists
assert — what makes a re-run of a completed ceremony refuse instead of
clobber, and what catches a manual tag racing the merge. If the release
truly exists, there is nothing to do: this red is the system declining to do
the thing twice. If the tag exists but the release does not (a manual tag
won the race, or
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish
step itself failing after the tag), recover by the tag door: delete and
re-push the tag, or `gh release create` by hand from a fixed tree.
> direct push refused (branch protection?) — opening the bump PR instead
[L302L310](.github/workflows/release.yml#L302-L310) — loud, but not a
[L293L301](.github/workflows/release.yml#L293-L301) — loud, but not a
refusal: the post-release `-dev` bump could not push directly, so the run
opened a `release`-labeled bump PR itself. Your move: merge it promptly —
until it lands, main is sitting bare, where a dev install impersonates the
@ -505,35 +503,29 @@ release and the
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
open.
### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410))
### The tag door refused ([release.yml](.github/workflows/release.yml#L303-L371))
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
[L356L359](.github/workflows/release.yml#L356-L359). The message is the
[L333L337](.github/workflows/release.yml#L333-L337). The message is the
remedy.
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
[L368L374](.github/workflows/release.yml#L368-L374). The tagged tree was
[L346L349](.github/workflows/release.yml#L346-L349). The tagged tree was
never stamped. Assemble the section
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
delete and re-push the tag.
> release '$VER' already exists — refusing to re-release, creating nothing.
[L375L390](.github/workflows/release.yml#L375-L390). A published release is
never replaced by the fallback. If it is correct, there is nothing to do; if
it is wrong, correct that published artifact deliberately before retrying.
### The re-arm refused ([release.yml](.github/workflows/release.yml#L276-L310))
### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301))
The bump belongs to the merge door alone — the tag door deliberately does not
rewrite main ([L325L329](.github/workflows/release.yml#L325-L329)) — and it
rewrite main ([L303L307](.github/workflows/release.yml#L303-L307)) — and it
runs *after* the tag, the notes and the publish. So a refusal here leaves a
real release standing behind a main that never re-armed — the release exists,
and main is left *armed to impersonate* it, still reading the version it just
shipped ([L275](.github/workflows/release.yml#L275)). That is the one failure
shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure
in this catalog whose remedy is a manual bump, not a re-run.
> version_next_dev: refusing '$ver' — expected bare X.Y.Z
@ -581,7 +573,7 @@ In every case the remedy has the same shape — bump `VERSION` (or the
shipped version was bare, and where it was an rc, whatever you have decided
comes next. Note that a *push* refusal is not one of these — branch
protection is expected, and the step opens the bump PR itself rather than
failing ([L302L310](.github/workflows/release.yml#L302-L310)).
failing ([L293L301](.github/workflows/release.yml#L293-L301)).
### Red main that is not the release workflow

View file

@ -151,7 +151,7 @@ window stops advancing along that edge (#329).
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).
mints 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

View file

@ -123,9 +123,9 @@ saw Y" outranks one that says "this looks like it might".
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a proposal where humans had their say. If you think the
above it, in a discussion where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the
proposal, while still reviewing the implementation against the spec as
discussion, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge.

View file

@ -1,25 +1,22 @@
# TRIAGE.md — the triage role
You are the only door work issues come through. Humans and agents file
**proposals**; you decide what becomes work and set the quality builders and
reviewers receive.
You are the only door issues come through. Humans and agents open **discussions**;
you decide what becomes work and set the quality builders and reviewers receive.
## Why this door exists
Proposals may be ambiguous; work issues may not: a builder must be able to
execute one **without asking anything**. One accountable role keeps builders
from guessing.
Discussions may be ambiguous; issues may not: a builder must be able to execute
one **without asking anything**. One accountable role keeps builders from guessing.
## Your inputs
- **Every open proposal** in the repo you serve.
- **Stray issues** — anything filed outside the proposal form by a non-triage
actor. Label it
- **Every open discussion** in the repo you serve.
- **Stray issues** — anything filed directly, by anyone. Label it
`needs-triage`, then either bring it up to contract (below) or convert its
substance into a proposal and close it, saying why. Route the work
substance back into a discussion and close it, saying why. Route the work
without shaming the filer.
## For each proposal, converge on exactly one outcome
## For each discussion, converge on exactly one outcome
1. **Answer.** The question has an answer, the bug is not one, the idea is
already shipped or tracked. Link the code, doc, or issue; mark answered.
@ -35,9 +32,9 @@ from guessing.
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
including its options, recommendation, blocked/continues statement, and
reversible-only default rules (#50 D12D13).
The proposal is where humans decide; wait there. When the decision
The discussion is where humans decide; wait there. When the decision
blocks something already on the board — an existing issue, or minted work
a proposal's ruling gates — set `needs-ruling` on it too, so the board
a discussion's ruling gates — set `needs-ruling` on it too, so the board
shows where the human's turn is; the issue keeps its queue label.
When you direct a builder to hold a claim, say the claim is **parked**,
name what it waits on, and set `attention` so the assignee's ack is visible
@ -64,7 +61,7 @@ from guessing.
This is not a substitute for minting work or for `needs-ruling`.
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
where it belongs if anywhere, close. A refusal with reasons is a good
outcome; a zombie proposal is not.
outcome; a zombie discussion is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
@ -74,7 +71,7 @@ Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the proposal it came from,
- **Context**: why this exists, with links — the discussion it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an
@ -162,4 +159,4 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.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
refusal into a vague issue to avoid saying no.
- Mint a work issue to explore an idea — file a proposal instead.
- Mint an issue to "discuss" something — that is a discussion.

View file

@ -1 +1 @@
0.6.4-dev
0.6.3-dev

View file

@ -33,12 +33,6 @@ set -euo pipefail
# no '## ' heading, and changelog_section extracts the body below HEAD's
# heading — so the date HEAD stamped into its heading never enters the
# comparison, and a date difference can never masquerade as a prose one.
#
# This guard narrows, but cannot close, the target-movement window: it sees a
# fragment present when CI reads the target ref, but one can still land after
# the final run and before merge. Requiring release PRs to be up to date with
# their target branch before merge is the repository setting that closes that
# residual window (#253).
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
@ -175,37 +169,6 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
failures=0
# Refusal: the target branch gained a fragment after this release PR's merge
# base, so the ceremony could not have consumed it. Merging this tree would
# strand that fragment for the next release and misattribute when it shipped.
stranded=""
while IFS= read -r -d '' entry; do
meta="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
name="${path##*/}"
case "$otype:$name" in
blob:README.md) ;;
blob:*.md)
if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then
stranded="${stranded} ${path}"$'\n'
fi
;;
esac
done < <(git ls-tree -z "$base_ref" -- "$dir/")
if [ -n "$stranded" ]; then
{
echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:"
echo
printf '%s' "$stranded"
echo
echo " Merging now would strand these entries for the next release and"
echo " misattribute when they shipped."
echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'."
} >&2
failures=$((failures + 1))
fi
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
# ceremony deletes exactly what it assembles (#112) — a fragment that
# survives its own release sits in the directory and is assembled AGAIN

View file

@ -208,14 +208,14 @@ claim_reclaim_marker() { # $1 = last activity epoch
refs_references() { # PR body on stdin -> local issue numbers named by Refs
awk '
{
rest = tolower($0)
while (match(rest, /(^|[^[:alnum:]_-])refs[[:space:]:]+(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/)) {
token = substr(rest, RSTART, RLENGTH)
sub(/^.*refs[[:space:]:]+/, "", token)
print token
# Retain the token final byte so ^ cannot turn a concatenated
# alphanumeric suffix into a fresh keyword boundary (#234).
rest = substr(rest, RSTART + RLENGTH - 1)
line = $0
lower = tolower(line)
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) {
line = substr(line, RSTART + RLENGTH)
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
sub(/[.(;].*/, "", line)
print line
}
}
}
' | issue_references \

View file

@ -270,8 +270,7 @@ set_required_bots() { # the PR author is recused by construction
# The state machine. Pure functions over these globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# BASE_SHA the PR's base branch head
# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref)
# BASE_SHA the PR's base branch head (the release-shape guard's ref)
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted, gradeable reviews
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
@ -716,7 +715,7 @@ offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock p
needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment
attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label
release|0E8A16|Release flow and version/packaging work
needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal
needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion
@ -724,7 +723,7 @@ epic|5319E7|Organizes other issues via a dependency-ordered task list — builde
EOF
}
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` belongs in a proposal, not a work issue
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` is a discussion
# One registry, kept beside core_label_rows() for the same reason those rows
# are not in labels.conf: a rule that must hold in every governed repo
# cannot live in a per-repo file. The six names match LABELS.md exactly.
@ -919,8 +918,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# Drafts are exempt (the build phase is the builder's); the version
# reads cost two API calls and only on PRs missing the label.
if [ "$DRAFT" != true ] && ! has_label release; then
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")"
fi
# ---- merge-next: cleared, never set ----------------------------------
@ -1033,7 +1031,6 @@ main() {
set_required_bots "$AUTHOR"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
MERGE_BASE_SHA="$(jq -r '.merge_base // empty' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
# This allow-list answers whether a row is a submitted, gradeable review;
# bot_verdict separately answers what that submitted verdict says (#235).

3
changelog.d/238.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Review-round state now reads each forge's live review-request set directly, so stale Forgejo approvals no longer hand an in-progress fix round back to the panel (#238).

3
changelog.d/240.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).

View file

@ -1,7 +0,0 @@
### Fixed
- The upstream deferral record now names its floor and a dated measurement instead of a frozen `0.7.4` ceiling that expired before it was written (#269).
### Changed
- `docs/UPSTREAM-SYNC.md` now records unconditionally that the next sync campaign merges rather than ports, and so advances the ancestry baseline (#269).

View file

@ -1,3 +0,0 @@
### Fixed
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).

View file

@ -1,3 +0,0 @@
### Fixed
- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273).

View file

@ -1,3 +0,0 @@
### Fixed
- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275).

View file

@ -337,18 +337,14 @@ The labels automation is two reusable workflows since #209, adopted
together at the same pin:
- **`labels.yml`** — the event-facing half, called on PR and issue events.
Same-repository PRs keep two write-capable jobs: additive path-based
`scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
dispatching the consumer's sweep caller (a REST `POST` to the forge's own
Two jobs: additive path-based `scope:*` labels, and a few-seconds
`trigger` job that wakes the sweep by dispatching the consumer's sweep
caller (a REST `POST` to the forge's own
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
plain `GITHUB_TOKEN``workflow_dispatch` is
one of the two documented exemptions from the token's no-retrigger rule,
so no PAT anywhere in the path and no loop: the sweep dispatches
nothing). On this Forgejo a fork-headed `pull_request_target` token is
read-only, so those two jobs do not run. A successful `fork_head` job names
the disposition: the scheduled sweep later reconciles state, blockers, and
handoff, while path-derived `scope:*` labels are not applied to fork heads.
Apply those scope labels manually when an outside contribution needs them.
nothing).
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
handoff, stale status, the issue work queue, and the `needs-ruling`
invariants on both surfaces — the bare-flag check and the 7-day
@ -360,10 +356,8 @@ together at the same pin:
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
and its `--failed`/`--job` forms all decline a queue-displaced run).
Behind its own caller, a displaced sweep cancels on the
Actions tab, attached to no PR. Same-repository PR checks show `scope` and
the green `trigger`; fork-headed PRs show the green `fork_head` disposition
and wait for scheduled state, blocker, and handoff reconciliation. The sweep
does not supply their path-derived scope labels.
Actions tab, attached to no PR; PR checks show `scope` and the green
`trigger` only.
The consumer keeps its path mapping in `.github/labeler.yml` and its
review panel plus scope taxonomy in `.github/labels.conf`.
@ -390,14 +384,11 @@ The complete event-facing caller is:
name: labels
on:
pull_request_target:
# These carry the head/draft/review facts state:* derives from. Same-repo
# heads take the instant write + sweep-dispatch path; this Forgejo gives
# fork heads a read-only token, so state, blocker, and handoff reconciliation
# waits for the scheduled sweep; path-derived scope labels require a manual
# write when wanted.
# labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
# on every push. review_requested/review_request_removed shipped in 0.3.0
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
# Fork PRs; these carry the head/draft/review facts state:* derives from.
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
# synchronize re-derives on every push. review_requested/review_request_removed
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
# blocker:unrequested when the panel is asked.
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block.
@ -434,24 +425,22 @@ name: labels-sweep
on:
# The consumer owns this cadence (#203). Hourly is the recommended default
# when no other engine drives board state: the cron is then the sweep's only
# wake for a review verdict landing (the labels caller has no
# pull_request_review trigger), blocker:ci-red
# wake for four transition classes — a review verdict landing (no
# pull_request_review trigger on the labels caller), blocker:ci-red
# set/cleared, blocker:conflict when another PR merges under this one, and
# time-based stale / 48h claim-reclaim, plus every state, blocker, and handoff
# transition for a fork-headed PR on this Forgejo. The sweep never applies
# path-derived scope labels. Issue events and same-repo PR events carry the
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
# latency on the scheduled classes while cutting nominal
# sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
# the cron: it is their discovery path. If another engine writes some of
# those transitions, only the classes with no other writer bound the cadence;
# relax it only as that list shrinks.
# latency on those four while cutting nominal scheduled sweeps from four an
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
# wakes this workflow with bootstrap=no on every issue and same-repo PR
# event, so the declared input is part of the contract: a dispatch naming an
# undeclared input is refused, and the trigger job goes loudly red.
# wakes this workflow with bootstrap=no on every board event, so the
# declared input is part of the contract: a dispatch naming an undeclared
# input is refused, and the trigger job goes loudly red.
workflow_dispatch:
inputs:
bootstrap:
@ -488,22 +477,17 @@ repositories allow check data to be read regardless, but a private consumer
needs the explicit reads above; without them the failure appears as an empty
`state:*` axis on the board rather than a red workflow run. The labels
caller's `actions: write` is different — it is required everywhere, public
repos included: the trigger job's dispatch is a write. Without it, issue and
same-repository PR event runs go red at the trigger. Fork-headed PR runs do
not enter that write path on this Forgejo; they remain green and depend on a
healthy scheduled sweep for state, blocker, and handoff reconciliation. That
sweep does not apply their path-derived scope labels.
repos included: the trigger job's dispatch is a write, and without it every
event run goes red at the trigger.
**The failure mode to know before bumping**: a consumer that bumps its pin
to a #209-carrying tag without adding the sweep caller gets a loud red trigger
on every issue and same-repository PR event (workflow-not-found; likewise on a
sweep caller missing its `bootstrap` input, or a labels caller missing
`actions: write`). Fork-headed PR runs deliberately skip that trigger and stay
green, so their correctness is proven by the sweep caller's presence and its
latest scheduled run, not by the PR check alone. Never read a green
`fork_head` disposition as evidence that the scheduled sweep exists. Make the
adoption one atomic PR — pin bump, sweep caller file, and `actions: write` line
together.
to a #209-carrying tag without adding the sweep caller keeps green-looking
silence nowhere — the trigger job goes **red on every PR and issue event**
(workflow-not-found; likewise on a sweep caller missing its `bootstrap`
input, or a labels caller missing `actions: write`), and event-woken sweeps
stop until the caller lands. That loudness is deliberate: never read
silence, or a green `scope` alone, as health. Make the adoption one atomic
PR — pin bump, sweep caller file, `actions: write` line together.
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
@ -554,25 +538,20 @@ carrying the split:
`actions: read` today (crew does); the trigger job's dispatch is a
write. The sweep caller keeps `actions: read`.
Bump without the sweep caller and the trigger job goes red on every issue and
same-repository PR event. Fork-headed PRs stay green, receive state, blocker,
and handoff reconciliation only from the scheduled sweep, and never receive
path-derived scope labels automatically; apply those manually when wanted.
Never split these four edits across PRs.
Bump without the sweep caller and the trigger job goes red on every PR
and issue event — the loud failure mode above — so never split these
four edits across PRs.
`pull_request_target` is intentional: same-repository PRs keep the base
repository's write token without executing PR code. This Forgejo still gives
fork-headed `_target` runs a read-only token, so they attempt no writes. The
scheduled sweep later reconciles state, blockers, and handoff; it does not
apply path-derived scope labels to those heads. The reusable workflows check
`pull_request_target` is intentional: fork PRs need the base repository's
token to write labels. The reusable workflows execute no PR code. They check
out only the consumer's base branch and the pinned ceremony implementation.
The #52 ruling invariants ride exactly these triggers — but the caller above
is no longer the #18 shape, so adopting current triggers is a stub edit, not
a bare pin bump. `review_requested` and `review_request_removed` on
`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
`blocker:unrequested` the moment the panel is asked on a same-repository head;
fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
events up by pinning `0.3.0` or later, never through mixed refs.
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
clears `blocker:unrequested` the moment the panel is asked, without which a
quiet repo wears that flag until the backstop cron; a consumer picks them up
by pinning `0.3.0` or later, never through mixed refs.
`.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, zero or more optional per-author panel rows, and
@ -605,7 +584,7 @@ goes down. Add the row only at or after the pin bump that carries it, never
before it and never through mixed refs.
Both actor lists are whitespace-separated. `triage-actors` names the identities
allowed to mint work issues without the sweep applying `needs-triage`. Label rows use exactly
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
`name|color|description`; blank lines are ignored and extra pipes are refused.
**Every account in `panel=` must be able to read the repository.** Requesting a
@ -851,16 +830,15 @@ published release.
## Adopting the agent team flow
The team flow (proposal → triage → work issue → build → review → human
The team flow (discussion → triage → issue → build → review → human
merge) is **optional per repo and separable from the release ceremony**:
a repo can adopt release-only and take the team flow later — incubator's
initial posture (#16). The model is this repo's own
[CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this");
this is the checklist:
- [ ] **Open the intake door** — install `proposal.yml` and the automatic
`needs-triage` flow. A repo whose forge provides Discussions may keep
them as its intake door and point `config.yml`'s contact link there.
- [ ] **Enable Discussions** — the triage door exists or the pipeline
has no intake.
- [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize
`.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md`
byte-identical to this repo at the pinned ref — plus the generated
@ -894,8 +872,7 @@ this is the checklist:
`workflow_dispatch` once ([above](#labels-automation)), or the hand
commands in [LABELS.md](../LABELS.md).
- [ ] **State the single-writer rule** in the repo's own docs: only
triage mints work issues; anyone may file a proposal, which triage
converts or refuses.
triage mints issues; everyone else opens discussions.
### The pin-bump procedure

View file

@ -30,33 +30,10 @@ The ancestry baseline therefore remains the full `.upstream-ref` value
`upstream-0.6.x` for upstream's line and bare `0.6.x` for releases cut on this
forge.
Upstream's drill-record fixes and the upstream `0.7.x` line remain deferred to
the next sync campaign. That line has no ceiling this file can hold still —
upstream tags roughly one release a week — so what is recorded here is its
floor and the date it was last measured: `0.7.0` onward, `0.7.6` newest as
measured 2026-08-27. Run `git ls-remote --tags` for today's ceiling. The
sentence this replaced froze `0.7.4` and was already a release behind on the
day it was written.
## The next campaign merges
The next sync campaign is a merge, not a port, and it therefore advances
`.upstream-ref` to the commit it merges. The operator ruled this on #268 on
2026-08-27, when release-init found no forge-local work to fill an `0.6.4` and
opened no window.
The reason is the ancestry baseline. `.upstream-ref` has been pinned at
`8c3a4d1` — upstream `0.6.0`, merged by #198 — because 0.6.2 came across as a
port and a port moves no ancestry. Another port would leave it pinned and
guarantee a third, larger campaign against a delta that is still growing.
Size it before starting. With upstream's tags fetched read-only per step 1,
`git diff --shortstat 8c3a4d1dee2bdb5ac06a632a285bb65ab2615214 0.7.6`
was 92 files and +25,121/-971 over 488 commits when measured 2026-08-27, and
the three action scripts the forge delta is heaviest in all move:
`runner-isolated` +1619, `issueflow-reconcile` +913, `labels-reconcile` +889.
Step 4 of the procedure below — the audit of what merged *without* conflicting
— is where that size is actually paid for.
Upstream's drill-record fixes and the upstream `0.7.0` through `0.7.4` line
remain deferred to the next sync campaign. If that campaign merges rather
than ports, it is the campaign that advances `.upstream-ref`; another port
leaves the ancestry baseline unchanged.
## The standing resolutions

View file

@ -1,74 +0,0 @@
# 0.6.3 — drill record
Measured 2026-08-26 on the `release-0.6.3` candidate branch, canonical
candidate SHA `03cb69d`. All three measurements below were taken at that head,
not copied from an earlier record.
## Scope ruling — doors unchanged, no disposable-repo rehearsal
**The last rehearsed tag is `0.6.1`, not `0.6.2`.** `drills/0.6.2.md` is itself
a doors-unchanged assertion, and `drills/README.md` anchors the baseline to the
last *rehearsed* tag precisely so one such assertion cannot chain from another
while the doors drift a small diff at a time. The baseline used here is
therefore `0.6.1`, which widens the measured window rather than narrowing it.
All three conditions hold at the candidate head:
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes only
the `CEREMONY_SELF_REF` pin line in `.github/workflows/release.yml`, from
`0.6.1` to `0.6.3`:
```diff
@@ -129,7 +129,7 @@ env:
- CEREMONY_SELF_REF: "0.6.1"
+ CEREMONY_SELF_REF: "0.6.3"
```
No release-door logic, decision table, fact gathering, version handling,
changelog handling, forge adapter or publish step changed across two
releases. The measured diff is one line.
2. The measured release path is exactly the output of
`.github/scripts/release-path.sh` at this head:
```text
.github/workflows/release.yml
bin/
lib/version.sh
lib/decide.sh
lib/facts.sh
lib/changelog.sh
lib/forge.sh
```
3. `drills/0.6.1.md` records a **full rehearsal** — run 2026-08-09 by
`codex-reviewer-andresmgsl` against release PR !226 — its release is
published, and `main` was re-armed to `0.6.2-dev` after it by
`5693bee chore: bump main to 0.6.2-dev`.
## What this release contains
Nine fragments consumed into `## 0.6.3`: #234, #240, #241, #243, #247, #251,
#253, #263, #265. None of them touches the release path; the list is
board-flow, labels-reconcile, docs and forge-adapter work.
## What is NOT asserted
This record asserts only that a fresh disposable-repo rehearsal would execute
the same release-door bytes as `0.6.1`'s did. It does not assert that the
doors were re-exercised, and it is not a substitute for the rehearsal owed at
the next release-path change. Per `drills/README.md`, the release PR's panel
verifies this claim like any other evidence, and **if any reviewer rules a full
drill owed, that verdict wins.**
## Note on 0.6.2's red `self-guards`
Recorded here because the next reader of `drills/` will see it. The `0.6.2`
tag's commit `5a8fce83` is red on `CI / self-guards``changelog-armed`
correctly refused a tree in which `changelog.d/238.md` was still unconsumed,
because that fragment landed on `main` after `changelog-assemble 0.6.2` had
already run in the release branch. The content shipped correct (the #238 entry
is in the published `0.6.2` notes) and the strand was consumed on `main` by
`f221647`. The systemic guard against the race landed in !255, *"refuse release
PRs that strand target fragments"*, which is why this candidate was checked
against it before opening.

View file

@ -84,10 +84,10 @@ drill owed, that verdict wins.
except the `CEREMONY_SELF_REF` pin line in
`.github/workflows/release.yml`.
2. The release path is exactly the output of
`.github/scripts/release-path.sh`. Run the script and paste its output into
the record; measure the diff with
`git diff <last-rehearsed-tag>..HEAD -- $(sh .github/scripts/release-path.sh)`.
The script's contract test keeps its list and the workflow's direct and
`.github/scripts/release-path.sh`: `.github/workflows/release.yml`, `bin/`,
`lib/version.sh`, `lib/decide.sh`, `lib/facts.sh`, and
`lib/changelog.sh`. The script is the record author's copy-paste source;
its contract test keeps this inline list and the workflow's direct and
transitive dependencies in agreement.
3. The last rehearsed tag's own record is a full rehearsal, its release is
published, and `main` was re-armed to `-dev` after it.

View file

@ -25,12 +25,12 @@ forgejo_api_base() {
# THE TRAP, measured on this instance with REPO unset (#191, caught by
# @kimi on !193 before it shipped):
#
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
# 404s; a repo-less path read
# as "the published release
# does not exist" — and the
# nothing-exists assert would
# then proceed to CREATE)
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
# 404s; a repo-less path read
# as "the release does not
# exist" — and the
# nothing-exists assert would
# then proceed to CREATE)
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
# PR behind it, read as none)
#
@ -468,10 +468,6 @@ forge_pr_view() {
$st.statuses[]? | {
__typename: "StatusContext",
context: .context,
# Forgejo carries the workflow name only as the context prefix;
# no separator means no proven workflow, so never guess (#243).
workflowName: ((.context // "")
| if contains(" / ") then split(" / ")[0] else "" end),
state: (.status | ascii_upcase),
# checks_state groups repeated contexts and takes the NEWEST by
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
@ -672,31 +668,22 @@ forgejo_read_code() {
rm -f "$hdr"
}
# forge_release_exists <tag> — prints `yes` for a published release and `no`
# for a draft or a 404. A non-zero exit means the read did not complete and
# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
# the read did not complete and the answer is UNKNOWN; the caller must not
# treat that as `no` (#191).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" body code draft
local tag="${1:?forge_release_exists: tag required}" body code
body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
rm -f "$body"
case "$code" in
2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$body"
echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2
return 1
fi
[ "$draft" = true ] && echo no || echo yes
;;
2*) echo yes ;;
404) echo no ;;
*)
rm -f "$body"
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
return 1
;;
esac
rm -f "$body"
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
@ -774,74 +761,20 @@ forgejo_urlencode() {
jq -rn --arg s "${1-}" '$s|@uri'
}
# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
# post-create failure. The caller has already printed the original failure;
# cleanup can add evidence but must never replace that diagnosis (#271).
forgejo_release_cleanup_draft() {
local id="${1:?forgejo_release_cleanup_draft: id required}"
local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup
if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then
[ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2
echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2
fi
return 0
}
# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft,
# uploads every asset, then publishes it. Any failure after create removes the
# draft, so the function leaves either a complete published release or nothing.
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
# uploads each asset to the created release. The release id comes back from
# the create, so no second lookup is needed.
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}" out id base token
local existing code draft existing_id
shift 3
# A previous rollback whose DELETE failed must not wedge the next attempt.
# Remove only a draft for this exact tag; a published release is never
# touched and its create will retain Forgejo's ordinary conflict refusal.
existing="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; }
case "$code" in
2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$existing"
echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2
return 1
fi
if [ "$draft" = true ]; then
existing_id="$(jq -r '.id // empty' "$existing")"
if [ -z "$existing_id" ]; then
rm -f "$existing"
echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2
return 1
fi
echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2
if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then
rm -f "$existing"
echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2
return 1
fi
fi
;;
404) ;;
*)
rm -f "$existing"
echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2
return 1
;;
esac
rm -f "$existing"
out="$(forgejo_write POST "repos/$REPO/releases" \
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
'{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1
id="$(printf '%s' "$out" | jq -r '.id // empty')"
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
if ! base="$(forgejo_api_base)"; then
forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
[ "$#" -gt 0 ] || return 0
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local f name
for f in "$@"; do
@ -855,16 +788,8 @@ forge_release_create() {
curl -sS -f -X POST -H "Authorization: token $token" \
-F "attachment=@$f" \
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|| {
echo "forge_release_create: asset upload failed for '$f'" >&2
forgejo_release_cleanup_draft "$id" "$tag"
return 1
}
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; }
done
if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then
forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
}
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes

View file

@ -1,81 +0,0 @@
#!/usr/bin/env bash
# lib/preflight.sh — the merge door's resume decision, pure and exhaustively
# tested (issue #273).
#
# A merge-door run creates the tag before the artifact hook and release. A
# failed hook or publish therefore leaves a tag but no release. Re-running the
# same merge commit must resume after that irreversible step; a published
# release or a tag naming another commit must still refuse.
#
# Pure: no repository or forge reads. The workflow establishes four facts:
#
# VER the version being released
# MERGE_SHA the commit this door would tag
# TAG_SHAS object names returned for the direct and peeled tag refs,
# newline-separated; empty means the tag is absent
# RELEASED yes|no — whether a published release for VER exists
#
# Output: resume=yes or resume=no on stdout, notices to stdout, refusals to
# stderr, return 1 on refusal.
#
# The decision table (this IS the spec — issue #273):
#
# | # | RELEASED | TAG_SHAS contains MERGE_SHA | result |
# |---|----------|------------------------------|---------------------------|
# | 1 | yes | either | REFUSE: already released |
# | 2 | no | empty | resume=no: ordinary run |
# | 3 | no | yes | resume=yes + resume NOTICE |
# | 4 | no | non-empty, no | REFUSE: tag is elsewhere |
release_preflight() {
local tag_sha sha
if [ -z "${VER:-}" ]; then
printf '%s\n' "VER is empty — the caller failed to establish the release version. Refusing to decide — creating nothing." >&2
return 1
fi
if [ -z "${MERGE_SHA:-}" ]; then
printf '%s\n' "MERGE_SHA is empty — the caller failed to establish the merge commit. Refusing to decide — creating nothing." >&2
return 1
fi
if [ -z "${RELEASED:-}" ]; then
printf '%s\n' "RELEASED is empty — the caller failed to establish whether release '$VER' exists. Refusing to decide — creating nothing." >&2
return 1
fi
case "$RELEASED" in
yes | no) ;;
*)
printf '%s\n' "RELEASED='$RELEASED' — expected yes or no. Refusing to decide — creating nothing." >&2
return 1
;;
esac
# Row 1 comes first: deleting a tag under a standing release never makes
# that release safe to recreate.
if [ "$RELEASED" = yes ]; then
printf '%s\n' "release '$VER' already exists — this release already happened; refusing to re-release, creating nothing." >&2
return 1
fi
# Row 2: an absent tag is the ordinary first run.
if [ -z "${TAG_SHAS:-}" ]; then
printf '%s\n' 'resume=no'
return 0
fi
# Row 3: compare each object name as a whole line. For an annotated tag the
# direct ref names the tag object and the peeled ref names MERGE_SHA.
while IFS= read -r sha; do
if [ "$sha" = "$MERGE_SHA" ]; then
printf '%s\n' "NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run."
printf '%s\n' 'resume=yes'
return 0
fi
done <<<"$TAG_SHAS"
# Row 4: the first object name is enough to diagnose the conflicting tag;
# MERGE_SHA is printed beside it so the operator sees both sides.
tag_sha="${TAG_SHAS%%$'\n'*}"
printf '%s\n' "tag '$VER' already exists at $tag_sha but this run would tag $MERGE_SHA — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit." >&2
return 1
}

View file

@ -217,49 +217,6 @@ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
run survivor base
# A release PR can be faithful to its merge base while the target branch moves
# ahead and gains a fragment during review. That target-only fragment was not
# available to the ceremony, so merging the PR would strand it for the next
# release. The guard must read the target ref as well as their merge base.
seed_flat target-stranded
ceremony target-stranded 0.2.0 2026-07-24
commit_head target-stranded
git -C "$TMP/target-stranded" switch -q base
printf -- '- Landed while the release was under review (#30).\n' \
>"$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment
git -C "$TMP/target-stranded" switch -q main
check "a target-head fragment the release did not consume fails" 1 \
"changelog.d/30.md" run target-stranded base
check "the target-stranding refusal names the rebase remedy" 1 \
"rebase onto the target head" run target-stranded base
check "the target-stranding refusal names the assembler re-run" 1 \
"changelog-assemble '0.2.0'" run target-stranded base
# Removing the target-only fragment makes the same diverged fixture green:
# target drift itself is not the failure, only a stranded fragment is.
git -C "$TMP/target-stranded" switch -q base
rm "$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment-removed
git -C "$TMP/target-stranded" switch -q main
check "the same target fixture is green once no fragment is stranded" 0 \
"byte-for-byte" run target-stranded base
# Spell out the common harmless case independently: the target branch moved,
# but the advancing commit added no fragment.
seed_flat target-unrelated
ceremony target-unrelated 0.2.0 2026-07-24
commit_head target-unrelated
git -C "$TMP/target-unrelated" switch -q base
printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt"
git -C "$TMP/target-unrelated" add -A
git -C "$TMP/target-unrelated" commit -qm target-unrelated
git -C "$TMP/target-unrelated" switch -q main
check "a target head advanced without a fragment stays green" 0 \
"byte-for-byte" run target-unrelated base
# Fragments consumed, section never stamped: the prose went nowhere.
seed_flat halfdone
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"

View file

@ -693,11 +693,8 @@ pr_view_stub() {
}
# The FAILURE is older but listed second — array order would pick it.
FAKE_STATUS='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"},
{"context":"CI / test (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"Refs guard / refs-not-closing (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"codecov","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"}]}'
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
pr_view_stub
view_json="$(REPO=o/r forge_pr_view 5)"
@ -705,20 +702,6 @@ check "pr_view maps createdAt" 0 "" \
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
check "pr_view maps completedAt" 0 "" \
grep -q '"completedAt":' <<<"$view_json"
check "pr_view derives workflowName from the first context separator" 0 "" \
jq -e '[.statusCheckRollup[]
| select(.context == "labels / labels (pull_request)"
or .context == "CI / test (pull_request)"
or .context == "Refs guard / refs-not-closing (pull_request)")
| [.workflowName, .context]]
| unique == [
["CI", "CI / test (pull_request)"],
["Refs guard", "Refs guard / refs-not-closing (pull_request)"],
["labels", "labels / labels (pull_request)"]
]' >/dev/null <<<"$view_json"
check "pr_view leaves workflowName empty when the context has no separator" 0 "" \
jq -e 'any(.statusCheckRollup[];
.context == "codecov" and .workflowName == "")' >/dev/null <<<"$view_json"
check "pr_view maps mergeable to the UI string" 0 "" \
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
@ -952,25 +935,16 @@ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
# tag creation GitHub POST /git/refs
# Forgejo POST /tags (/git/refs is GET-only)
# release_stub <code> <body> [<code> <body>…] — a curl stub answering a
# sequence of canned responses and recording the method+path it was asked for.
# The cursor lives in a file because forge_release_create captures
# forgejo_write in a command substitution; an in-memory counter would advance
# only in that subshell and every later request would replay response one.
# release_stub <code> <body> — a curl stub answering one canned response and
# recording the method+path it was asked for.
release_stub() {
# Globals, not locals: the curl closure below runs long after this
# function returns, exactly as stub_writes does above.
STUB_CODES=() STUB_BODIES=()
while [ "$#" -gt 0 ]; do
STUB_CODES+=("$1") STUB_BODIES+=("$2")
shift 2
done
STUB_READ_CODE="${STUB_CODES[0]}" STUB_READ_BODY="${STUB_BODIES[0]}"
printf '0\n' >"$TMP/release-stub-cursor"
STUB_CODE="$1" STUB_BODY="$2"
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() {
local hdr="" out="" method=GET url="" payload="" fail_on_http=false
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
@ -978,32 +952,18 @@ release_stub() {
-X) method="$2"; shift ;;
-d) payload="$2"; shift ;;
-F) payload="$payload -F $2"; shift ;;
-f) fail_on_http=true ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
local index code body
if [ "$method" = GET ]; then
code="$STUB_READ_CODE" body="$STUB_READ_BODY"
else
index="$(cat "$TMP/release-stub-cursor")"
code="${STUB_CODES[$index]:-${STUB_CODES[${#STUB_CODES[@]} - 1]}}"
body="${STUB_BODIES[$index]:-${STUB_BODIES[${#STUB_BODIES[@]} - 1]}}"
printf '%s\n' "$((index + 1))" >"$TMP/release-stub-cursor"
fi
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$code" >"$hdr"
[ -n "$out" ] && printf '%s' "$body" >"$out"
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
if [ "$fail_on_http" = true ]; then
case "$code" in 2*) ;; *) return 22 ;; esac
fi
return 0
}
}
release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; }
GITHUB_API_URL=https://forge.example/api/v1
export GITHUB_API_URL
@ -1046,10 +1006,6 @@ check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknow
release_stub 200 '{"tag_name":"1.2.3"}'
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":true}'
check "forgejo: a draft is not a present release" 0 "no" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":false}'
check "forgejo: a published release is present" 0 "yes" forge_release_exists 1.2.3
release_stub 404 '{}'
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
release_stub 503 '{}'
@ -1073,22 +1029,9 @@ check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
writes_after forge_tag_create 1.2.3 cafebabe
printf 'notes body\n' >"$TMP/notes.md"
release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; }
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
release_stub 201 '{"id":42}'
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
check "the create stages a draft" 0 '"draft":true' cat "$WRITES"
zero_asset_writes() {
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
local got
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'POST repos/o/r/releases\nPATCH repos/o/r/releases/42' ] || { printf '%s\n' "$got"; return 1; }
}
check "the zero-asset path creates then publishes" 0 "" zero_asset_writes
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
# Assets: the hook contract permits any filename the consumer drops in
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
@ -1102,179 +1045,18 @@ check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
printf 'x\n' >"$TMP/release asset.tgz"
printf 'y\n' >"$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
release_stub 201 '{"id":42}'
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}'
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
check "the upload targets the created release id" 0 "releases/42/assets" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
check "the asset rides as a multipart attachment" 0 "attachment=@" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
happy_asset_writes() {
release_stub 201 '{"id":42}' 201 '{}' 201 '{}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" "$TMP/a&b.tgz" >/dev/null 2>&1
local got want
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*| -F.*|$)//')"
want=$'POST repos/o/r/releases\nPOST repos/o/r/releases/42/assets?name=release%20asset.tgz\nPOST repos/o/r/releases/42/assets?name=a%26b.tgz\nPATCH repos/o/r/releases/42'
[ "$got" = "$want" ] || { printf '%s\n' "$got"; return 1; }
}
check "assets upload in argument order before the release is published" 0 "" happy_asset_writes
upload_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "an upload failure returns non-zero" 1 "asset upload failed" upload_failure
check "an upload failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
flip_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a publish flip failure returns non-zero" 1 "HTTP 500" flip_failure
check "a publish flip failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
create_failure() {
release_stub 500 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create failure returns non-zero" 1 "HTTP 500" create_failure
check "a create failure has nothing to delete" 1 "" grep -q '^DELETE ' "$WRITES"
missing_release_id() {
release_stub 201 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create without an id keeps its refusal" 1 "returned no release id" missing_release_id
check "a create without an id deletes nothing unknown" 1 "" grep -q '^DELETE ' "$WRITES"
stranded_cleanup_failure() {
release_stub 201 '{"id":42}' 500 '{"message":"upload refused"}' 500 '{"message":"delete refused"}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "cleanup failure preserves the original failure" 1 "asset upload failed" stranded_cleanup_failure
check "cleanup failure also names the stranded draft" 1 "stranded draft 42 for tag '1.2.3'" stranded_cleanup_failure
release_stub 204 '{}' 201 '{"id":42}' 200 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":true}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
stranded_draft_order() {
local got
got="$(grep -v '^GET ' "$WRITES" | head -2 | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'DELETE repos/o/r/releases/41\nPOST repos/o/r/releases' ] || { printf '%s\n' "$got"; return 1; }
}
check "a stranded same-tag draft is deleted before create" 0 "" stranded_draft_order
release_stub 409 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES"
# The merge door's preflight step is extracted and executed. Its network
# edges are stubbed at the boundary, while the real pure decision library
# consumes the gathered facts (#273).
MERGE_PREFLIGHT="$TMP/merge-preflight.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-merge.steps[] | select(.id == "preflight") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$MERGE_PREFLIGHT"
chmod +x "$MERGE_PREFLIGHT"
mkdir -p "$TMP/merge-preflight-lib/lib" "$TMP/merge-preflight-bin"
ln -s "$ROOT/lib/preflight.sh" "$TMP/merge-preflight-lib/lib/preflight.sh"
# shellcheck disable=SC2016 # expanded when the generated helper runs
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/merge-preflight-lib/lib/forge.sh"
# shellcheck disable=SC2016 # expanded when the PATH stub is executed
printf '%s\n' \
'#!/usr/bin/env bash' \
'case "$GIT_LS_REMOTE" in' \
' absent) exit 2 ;;' \
' error) exit 128 ;;' \
' *) printf "%s\n" "$GIT_LS_REMOTE" ;;' \
'esac' >"$TMP/merge-preflight-bin/git"
chmod +x "$TMP/merge-preflight-bin/git"
merge_preflight_extracted() { [ "$(wc -l <"$MERGE_PREFLIGHT")" -ge 8 ]; }
run_merge_preflight() {
: >"$TMP/merge-preflight-output"
CEREMONY_DIR="$TMP/merge-preflight-lib" \
GITHUB_OUTPUT="$TMP/merge-preflight-output" \
PATH="$TMP/merge-preflight-bin:$PATH" \
VER=1.2.3 MERGE_SHA=1111111111111111111111111111111111111111 \
RELEASE_EXISTS="$1" GIT_LS_REMOTE="$2" "$MERGE_PREFLIGHT"
}
merge_preflight_output_is() {
run_merge_preflight "$1" "$2" >/dev/null && \
[ "$(cat "$TMP/merge-preflight-output")" = "$3" ]
}
check "the merge door's preflight is extracted" 0 "" merge_preflight_extracted
check "the merge door refuses an unreadable release state" 1 \
"refusing rather than assuming" run_merge_preflight error absent
check "the merge door refuses an unreadable tag state" 1 \
"could not read tag '1.2.3'" run_merge_preflight no error
check "ls-remote exit 2 is tag absence and proceeds" 0 "" \
merge_preflight_output_is no absent resume=no
check "a matching peeled ref records a resume" 0 "" \
merge_preflight_output_is no \
$'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/1.2.3\n1111111111111111111111111111111111111111\trefs/tags/1.2.3^{}' \
resume=yes
tag_step_uses_preflight() {
yq -r '.jobs.release-on-merge.steps[] | select(.name | test("tag the merge commit")) | .if' \
"$ROOT/.github/workflows/release.yml" | grep -q 'steps.preflight.outputs.resume'
}
check "the merge-door tag step is conditioned on the preflight output" 0 "" \
tag_step_uses_preflight
# The tag door's published-release guard is extracted and executed, like the
# other workflow-shell contracts in this repository. The tag itself is this
# door's premise, so only a published release refuses; adding the merge door's
# ls-remote half here would destroy the documented delete-and-re-push recovery.
TAG_GUARD="$TMP/tag-release-guard.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-tag.steps[] | select(.name == "no published release may exist") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$TAG_GUARD"
chmod +x "$TAG_GUARD"
mkdir -p "$TMP/tag-guard-lib/lib"
# shellcheck disable=SC2016 # expanded when the generated helper runs, not while this test writes it
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/tag-guard-lib/lib/forge.sh"
tag_guard_extracted() { [ "$(wc -l <"$TAG_GUARD")" -ge 8 ]; }
run_tag_guard() {
CEREMONY_DIR="$TMP/tag-guard-lib" VER=1.2.3 RELEASE_EXISTS="$1" "$TAG_GUARD"
}
tag_door_has_no_tag_exists_guard() {
! yq -r '.jobs.release-on-tag.steps[].run // ""' "$ROOT/.github/workflows/release.yml" | grep -q 'ls-remote'
}
check "the tag door's published-release guard is extracted" 0 "" tag_guard_extracted
check "the tag door refuses an existing published release" 1 "release '1.2.3' already exists" run_tag_guard yes
check "the tag door proceeds when no published release exists" 0 "" run_tag_guard no
check "the tag door refuses when release state is unreadable" 1 "refusing rather than assuming" run_tag_guard error
check "the tag door carries no tag-exists guard" 0 "" tag_door_has_no_tag_exists_guard
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
# --- the github twins address their own paths ----------------------------
. "$ROOT/lib/forge-github.sh"

View file

@ -101,28 +101,6 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)'
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
test "$(refs_references <<<"$refs_body")" = $'8\n12\n175'
# A Refs declaration binds one token, never the prose that follows it; every
# later occurrence starts a fresh declaration (#234).
# shellcheck disable=SC2016 # backticks are the fixture's literal Markdown
crew_round_line='- **Claude verified the safe `Refs #52`, TDD provenance, and inherited #65 CI failure.**'
check "Refs parser ignores issue prose after the bound token" 0 "" \
test "$(refs_references <<<"$crew_round_line")" = 52
check "Refs comma-list narrowing is deliberate" 0 "" \
test "$(refs_references <<<'Refs #8, #9')" = 8
check "a cross-repo Refs token stays non-local" 0 "" \
test -z "$(refs_references <<<'Refs heavy-duty/rig#4')"
check "prose after a cross-repo Refs token stays non-local" 0 "" \
test -z "$(refs_references <<<'Refs heavy-duty/rig#4, #12')"
check "parenthesized prose after a local Refs token is ignored" 0 "" \
test "$(refs_references <<<'Refs #175 (split from #150)')" = 175
check "every Refs occurrence on one line contributes its bound token" 0 "" \
test "$(refs_references <<<'Refs #8. Refs #9.')" = $'8\n9'
check "a concatenated Refs spelling is not a second keyword occurrence" 0 "" \
test "$(refs_references <<<'Refs #8Refs #9')" = 8
check "lowercase refs in unrelated prose declares nothing" 0 "" \
test -z "$(refs_references <<<'fix refs parsing from #200')"
check "Refs without a following token declares nothing" 0 "" \
test -z "$(refs_references <<<'Closes #40; refs: none')"
open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5'
check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \
open_pr_issues <<<"$open_records"

View file

@ -175,93 +175,6 @@ expect "an unreadable head version is silent — never nag on a guess" "" \
expect "a bare head over an unreadable base still warns" yes \
"$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)"
# The pure matrix above proves the warning predicate. These two fixtures drive
# the sweep boundary that chooses WHICH base tree feeds it (#275): the
# reporting Forgejo payload exposes the PR's moving base tip beside its fixed
# merge base, and using the former manufactures a downgrade after an
# intervening release. A missing merge-base field deliberately keeps the
# specified base-tip fallback for backends that do not supply that fact.
release_shape_ref_probe() { # $1 = phantom | bump | fallback
(
# shellcheck disable=SC2030 # this probe intentionally isolates its repository fixture
REPO=owner/repo
LABELS_CONF="$FIXTURE_CONF"
CEREMONY_FORGE=github
mode="$1"
refs="$RTMP/release-shape-$mode-refs"
: >"$refs"
case "$mode" in
phantom)
head_ver=1.3.0 base_tip_ver=1.4.0 merge_base_ver=1.3.0
merge_base_json='"merge-base"' ;;
bump)
head_ver=1.4.0 base_tip_ver=1.3.0 merge_base_ver=1.3.0
merge_base_json='"merge-base"' ;;
fallback)
head_ver=1.4.0 base_tip_ver=1.4.0 merge_base_ver=unused
merge_base_json=null ;;
*) return 2 ;;
esac
# shellcheck disable=SC2317 # reached through the GitHub backend selected above
gh() {
if [ "$1" = label ] && [ "$2" = list ]; then
core_label_rows | cut -d'|' -f1
return 0
fi
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '701\n'
return 0
fi
if [ "$1" = pr ] && [ "$2" = view ]; then
jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
return 0
fi
if [ "$1" = issue ] && [ "$2" = edit ]; then return 0; fi
case "$(forge_stub_path "$*")" in
*'repos/owner/repo/pulls/701 --jq .requested_reviewers'*) return 0 ;;
*repos/owner/repo/pulls/701/reviews*) return 0 ;;
*repos/owner/repo/pulls/701)
jq -n --argjson merge_base "$merge_base_json" \
'{draft:false,user:{login:"fixture-builder"},
head:{sha:"head"},base:{sha:"base-tip"},merge_base:$merge_base,
labels:[{name:"state:addressing"},{name:"blocked"}],
requested_reviewers:[],created_at:"2026-08-31T20:00:00Z"}' ;;
*repos/owner/repo/commits/head*) printf '2026-08-31T20:00:00Z\n' ;;
*repos/owner/repo/contents/VERSION\?ref=head*)
printf 'head\n' >>"$refs"
printf '%s' "$head_ver" | base64 ;;
*repos/owner/repo/contents/VERSION\?ref=base-tip*)
printf 'base-tip\n' >>"$refs"
printf '%s' "$base_tip_ver" | base64 ;;
*repos/owner/repo/contents/VERSION\?ref=merge-base*)
printf 'merge-base\n' >>"$refs"
printf '%s' "$merge_base_ver" | base64 ;;
*) printf '[]\n' ;;
esac
}
main
)
}
phantom_shape="$(release_shape_ref_probe phantom)"
expect "an unchanged branch cut before a later base release emits no release-shape warning" \
no "$(grep -q 'release-shaped' <<<"$phantom_shape" && echo yes || echo no)"
expect "the unchanged-branch guard compares head with the PR merge base" \
$'head\nmerge-base' "$(cat "$RTMP/release-shape-phantom-refs")"
bump_shape="$(release_shape_ref_probe bump)"
expect "a genuine version bump relative to the merge base keeps the warning text" \
yes "$(grep -qF '#701 is release-shaped (version 1.3.0 -> 1.4.0 at its head)' \
<<<"$bump_shape" && echo yes || echo no)"
expect "the genuine-bump guard also compares head with the PR merge base" \
$'head\nmerge-base' "$(cat "$RTMP/release-shape-bump-refs")"
fallback_shape="$(release_shape_ref_probe fallback)"
expect "a null merge base falls back to the base tip without guessing a warning" \
no "$(grep -q 'release-shaped' <<<"$fallback_shape" && echo yes || echo no)"
expect "the null merge-base fallback compares head with the base tip" \
$'head\nbase-tip' "$(cat "$RTMP/release-shape-fallback-refs")"
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"
@ -676,61 +589,6 @@ expect "a rollup of ONLY self entries is honestly NONE, never SUCCESS" NONE \
expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \
"$(rollup "[$(run_ a FAILURE),\
$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)"
# Forgejo exposes Actions checks only as commit statuses. Drive the real
# backend mapper here: fixtures that hand-write workflowName repeat the field
# omission that hid #243 instead of proving the adapter supplies it.
forgejo_rollup() {
local statuses="$1"
(
# shellcheck source=lib/forge-forgejo.sh
. lib/forge-forgejo.sh
forge_api() {
case "$1" in
repos/o/r/pulls/5)
printf '%s\n' '{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
;;
repos/o/r/commits/abc/status)
printf '%s\n' "$statuses"
;;
*) return 1 ;;
esac
}
REPO=o/r forge_pr_view 5
)
}
forgejo_self_red='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"},
{"context":"CI / test (pull_request)","status":"pending","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "Forgejo StatusContexts exclude a self FAILURE and retain pending work" PENDING \
"$(forgejo_rollup "$forgejo_self_red" | checks_state)"
forgejo_self_only='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "Forgejo StatusContexts containing only the self workflow are NONE" NONE \
"$(forgejo_rollup "$forgejo_self_only" | checks_state)"
forgejo_foreign_red='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"},
{"context":"CI / test (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "Forgejo StatusContexts retain a genuine foreign FAILURE" FAILURE \
"$(forgejo_rollup "$forgejo_foreign_red" | checks_state)"
forgejo_bare_red='{"state":"failure","statuses":[
{"context":"codecov","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "a bare Forgejo context survives a named self-workflow exclusion" FAILURE \
"$(forgejo_rollup "$forgejo_bare_red" | checks_state)"
forgejo_nested_name='{"state":"failure","statuses":[
{"context":"labels / nested / check","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
SELF_WORKFLOW="labels / nested"
expect "a Forgejo workflow name containing a separator is never guessed" FAILURE \
"$(forgejo_rollup "$forgejo_nested_name" | checks_state)"
SELF_WORKFLOW=""
expect "a bare Forgejo context also survives an empty self-workflow exclusion" FAILURE \
"$(forgejo_rollup "$forgejo_bare_red" | checks_state)"
# ...and an empty self filters NOTHING: outside Actions no workflow name is
# ambient, and the exclusion must never drop entries on a guess — the same
# displaced-self rollup keeps blocking there, all-cancelled context intact.

View file

@ -150,33 +150,4 @@ done
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types "$SELF" pull_request_target
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
# fork-headed pull_request_target run must attempt no write: both write-capable
# jobs exclude it, while one successful job explains exactly what the scheduled
# sweep does and does not supply. Require each full normalised expression to
# appear intact, so deleting or inverting one of its clauses fails the guard.
job_if_expression() { # $1 = file, $2 = job
yq -r ".jobs.$2.if // \"\"" "$1" |
tr '\n' ' ' |
awk '{$1=$1; print}'
}
check "scope writes only for a same-repo PR head" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && github.event.action != 'review_request_removed'" \
job_if_expression "$REUSABLE" scope
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 \
"github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository" \
job_if_expression "$REUSABLE" trigger
check "a fork-headed PR selects the successful explanation job" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository" \
job_if_expression "$REUSABLE" fork_head
fork_head_step() {
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
"$REUSABLE" | bash
}
check "the fork path distinguishes swept state from unsupported scope writes" 0 \
"read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" \
fork_head_step
summary

View file

@ -31,15 +31,6 @@ attention_row='attention|D93F0B|A demand is parked here for the assignee: pick u
check "attention core row is emitted once, byte-exact" 0 "1" bash -c \
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$attention_row"
needs_triage_row='needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal'
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "needs-triage core row is emitted once, byte-exact" 0 "1" bash -c \
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$needs_triage_row"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "core label rows carry no stale discussion path" 1 "" bash -c \
'source "$1"; core_label_rows | grep -i discussion' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh"
# shellcheck disable=SC2016 # fields are intentionally split in the nested shell
check "attention description survives label field splitting" 0 \
"A demand is parked here for the assignee: pick up the thread, ack by removing this label" \

View file

@ -1,121 +0,0 @@
#!/usr/bin/env bash
# Contract tests for lib/preflight.sh (issue #273) — every row of the
# merge-door resume table, offline. set -u, not -e: refusals are behavior for
# the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
PREFLIGHT="$ROOT/lib/preflight.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
VER=1.2.3
MERGE_SHA=1111111111111111111111111111111111111111
FOREIGN_SHA=2222222222222222222222222222222222222222
# preflight <VER> <MERGE_SHA> <TAG_SHAS> <RELEASED> — run the pure decision
# with exactly the four gathered facts in its environment.
preflight() {
# shellcheck disable=SC2016 # PREFLIGHT expands inside the isolated child
env VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" \
PREFLIGHT="$PREFLIGHT" bash -c '. "$PREFLIGHT"; release_preflight'
}
preflight_stdout() {
preflight "$@" 2>/dev/null
}
preflight_stderr() {
{ preflight "$@" >/dev/null; } 2>&1
}
refuses_without_output() {
local out rc
out="$(preflight "$@" 2>/dev/null)"
rc=$?
[ "$rc" -eq 1 ] && [ -z "$out" ]
}
# --- the four table rows ----------------------------------------------------
check "row 1: a published release refuses even with no tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "" yes
check "row 1: a published release refuses with the matching tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
check "row 1: a published release refuses with a foreign tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" yes
check "row 1: refusal emits no workflow output" 0 "" \
refuses_without_output "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
check "row 2: an ordinary first run proceeds" 0 "resume=no" \
preflight_stdout "$VER" "$MERGE_SHA" "" no
check "row 3: the matching tag resumes" 0 "resume=yes" \
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
check "row 3: resume notice names the previous failed publish" 0 \
"a previous run of this door tagged and then failed to publish" \
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
check "row 3: an annotated tag resumes when the peeled ref matches" 0 \
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
"$FOREIGN_SHA"$'\n'"$MERGE_SHA" no
check "row 3: an annotated tag resumes when the direct ref matches" 0 \
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
"$MERGE_SHA"$'\n'"$FOREIGN_SHA" no
check "row 4: a foreign tag refuses" 1 "tag '$VER' already exists" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: the refusal names the foreign tag SHA" 1 "$FOREIGN_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: the refusal names the merge SHA" 1 "$MERGE_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: refusal emits no workflow output" 0 "" \
refuses_without_output "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
# A ref object that merely contains MERGE_SHA is not the merge commit. Each
# ls-remote object name is compared as a whole line.
PREFIX_SHA="${MERGE_SHA%?}"
check "a prefix of MERGE_SHA does not resume" 1 "already exists at $PREFIX_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$PREFIX_SHA" no
check "a line containing MERGE_SHA does not resume" 1 \
"already exists at x${MERGE_SHA}y" \
preflight_stderr "$VER" "$MERGE_SHA" "x${MERGE_SHA}y" no
# --- fact validation --------------------------------------------------------
check "empty VER refuses" 1 "VER is empty" \
preflight_stderr "" "$MERGE_SHA" "" no
check "empty MERGE_SHA refuses" 1 "MERGE_SHA is empty" \
preflight_stderr "$VER" "" "" no
check "empty RELEASED refuses" 1 "RELEASED is empty" \
preflight_stderr "$VER" "$MERGE_SHA" "" ""
check "malformed RELEASED refuses" 1 "RELEASED='maybe' — expected yes or no" \
preflight_stderr "$VER" "$MERGE_SHA" "" maybe
# --- stream discipline and purity ------------------------------------------
notice_stays_on_stdout() {
local stdout stderr
stdout="$(preflight "$VER" "$MERGE_SHA" "$MERGE_SHA" no 2>"$TMP/preflight.err")"
stderr="$(cat "$TMP/preflight.err")"
[ -n "$stdout" ] && [ -z "$stderr" ]
}
refusal_stays_on_stderr() {
local stdout stderr rc
stdout="$(preflight "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no 2>"$TMP/preflight.err")"
rc=$?
stderr="$(cat "$TMP/preflight.err")"
[ "$rc" -eq 1 ] && [ -z "$stdout" ] && [ -n "$stderr" ]
}
no_tool_calls() {
! grep -v '^[[:space:]]*#' "$PREFLIGHT" | grep -Ewq 'git|gh|curl|wget'
}
check "resume notice and output stay on stdout" 0 "" notice_stays_on_stdout
check "refusal stays on stderr" 0 "" refusal_stays_on_stderr
check "preflight calls no git/gh/network tools" 0 "" no_tool_calls
summary

View file

@ -97,30 +97,6 @@ path_check() {
[ -z "$missing" ] && [ -z "$extra" ]
}
path_check_reports_only() {
local tree="$1" expected="$2" output rc
output="$(path_check "$tree" 2>&1)"
rc=$?
if [ "$rc" -ne 1 ] || [ "$output" != "$expected" ]; then
printf 'expected only: %s\ngot (exit %s): %s\n' \
"$expected" "$rc" "$output" >&2
return 1
fi
}
readme_has_no_path_enumeration() {
local token found=no
for token in \
bin/ lib/version.sh lib/decide.sh lib/facts.sh lib/changelog.sh lib/forge.sh
do
if grep -qF "$token" "$ROOT/drills/README.md"; then
printf 'drill doctrine enumerates release path: %s\n' "$token" >&2
found=yes
fi
done
[ "$found" = no ]
}
fixture() {
local name="$1" tree
tree="$TMP/$name"
@ -130,7 +106,6 @@ fixture() {
"\$ROOT/lib/changelog.sh" >"$tree/bin/assemble"
printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/preflight.sh"
# facts.sh sources BOTH on this tree: version.sh, and the forge shim #191
# put on the doors' path so a Forgejo consumer can publish (#198). The
# synthetic tree mirrors the real one, or every fixture below reports
@ -146,65 +121,61 @@ fixture() {
# Exact output is the record author's copy-paste source.
check "manifest prints the specified ordered release path" 0 \
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/preflight.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
bash "$PATH_SCRIPT"
check "real workflow and transitive dependencies match the manifest" 0 "" \
path_check "$ROOT"
check "drill doctrine does not duplicate the executable release path" 0 "" \
readme_has_no_path_enumeration
# A door growing a dependency must name the missing path (#237 D7).
tree="$(fixture missing)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
"\$CEREMONY_DIR/lib/version.sh" "\$CEREMONY_DIR/lib/ruling.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \
"\$CEREMONY_DIR/lib/ruling.sh" \
>"$tree/.github/workflows/release.yml"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a new workflow library fails with only its missing path" 0 "" \
path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
check "a new workflow library fails with its missing path" 1 \
"missing dependency: lib/ruling.sh" path_check "$tree"
# A library growing a sibling dependency in the production idiom must also
# name the missing path; a literal lib/ marker in a comment is not evidence.
tree="$(fixture missing-transitive)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml"
printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \
>>"$tree/lib/facts.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a new sibling library fails with only its missing path" 0 "" \
path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
check "a new sibling library fails with its missing path" 1 \
"missing dependency: lib/ruling.sh" path_check "$tree"
# A manifest may not rot into a safe-looking superset.
tree="$(fixture extra)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml"
sed -i '$ s|$| \\|' \
sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \
"$tree/.github/scripts/release-path.sh"
printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a path no door reads fails with only its stale path" 0 "" \
path_check_reports_only "$tree" "release-path: stale path: lib/ruling.sh"
check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \
path_check "$tree"
# Transitive sourcing is part of the derivation, not decoration.
tree="$(fixture transitive)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml"
# Only the version source is dropped; the forge source #191 added stays, or
# the fixture reports two stale paths and proves neither of them (#198).
printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
>"$tree/lib/facts.sh"
check "removing facts' version source fails with only its stale path" 0 "" \
path_check_reports_only "$tree" "release-path: stale path: lib/version.sh"
check "removing facts' version source fails as a stale path" 1 \
"stale path: lib/version.sh" path_check "$tree"
summary