changelog.d/253.md landed on main after this branch's point with the
cite trailing the period — the crew#309 shape, the fifth fragment to
arrive with it since #262 measured two. The criterion is that
changelog.d/ is clean at the head, and the head CI reviews is the merge
with main, which is where this one surfaced.
Refs #262.
The self-guards job checks out the merge of this branch with main, so
changelog.d/253.md from #285 reaches changelog_fragment_problem there and
nowhere else; the branch alone is green. Merged rather than rebased
because the round's reviewers track head SHAs.
Refs #262.
awk runs END on the way out of an exit from a main rule, so the length
row printed mid-file was followed by the citation row it outranks — the
internal protocol line landing inside the human-facing excerpt. Found by
claude-bot and kimi-bot in #262's first round, independently and with the
same reproduction.
The guard is the reported flag the empty-heading walk in this same
predicate already uses. The fixtures are the axis 57.md could not reach:
its over-bound entry is last, so only END's flush can print. 58.md puts
one before another bullet, 59.md before a heading and after a misplaced
cite. With lib/changelog.sh alone reverted they red, which is what the
green suite was hiding.
Refs #262.
The step-replay job builds a consumer tree and runs the REAL assembler
over it, so its changelog.d/42.md is a fragment fixture like every one in
test/ — and the only one living outside it. #262's diff-surface criterion
says no workflow file; the criterion and a green head cannot both hold
here, and the fixture is the smaller thing to move.
Refs #262
Both findings are @codex's on !193 (#1583), and both are real.
The asset name travels as a QUERY VALUE, and the artifact-hook contract
permits any file the consumer drops in RELEASE_ASSETS_DIR. Raw
interpolation meant `release asset.tgz` made curl reject the URL outright
(exit 3), and '&', '#', '+', '%' silently changed the name or the query's
shape. `gh release create` handled all of those, so a 1:1 port had to.
Encoded through one boundary — jq's @uri, since jq is already a hard
dependency of this backend and a hand-rolled sed class is how the next
unescaped character gets through. Six backend cases cover it: the encoder
on a space and on the delimiters, uploads under both names, the created
release id in the path, and the multipart attachment. Mutation-checked:
dropping the encoder fails exactly the two name assertions.
docs/CONSUMERS.md's artifact-hook recovery still told operators to "run
`gh release create` by hand" and described the hook as running "before
`gh release create`" — on a Forgejo runner that is precisely the failure
this PR fixes. It now names the forge-neutral tag-door recovery first and
shows both clients for the manual path, without regressing the GitHub
guidance.
1035 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scoped to the fixtures the new rule actually binds: a fragment whose
predicate complaint is already its name, a smuggled heading, a dangling
heading or the 300-character bound is left alone, so the diagnosis it
tests is still the one it draws. The section-predicate fixtures are
untouched (D4).
The computed-length fixtures keep their measured lengths: the cite is
seven characters, so an entry that must measure exactly 300 builds 293
of the run and lets the cite carry the rest. 33.md's cite lands on the
last continuation line, which is the wrapped-citation case.
Refs #262
'- Fixed entry.' is shared between the dangling-heading fragment fixture
and the section-predicate fixture, so the global replace crossed D4's
line. Next commit filters to fragments that actually red on the cite rule.
Refs #262
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
release-exercise stubs `gh` to answer the one API fact the ceremony path
consults. Since #191 facts.sh selects a backend, and on this Forgejo runner
it selected the forgejo one — which speaks curl and walked straight past the
stub to the live instance, read the fixture's SHA against the real
repository, found no merged release-labeled PR behind it and refused.
The exercise rehearses the WIRING — facts → decide → notes through the real
$GITHUB_OUTPUT plumbing. Which backend answers is lib/forge.sh's own
contract and is covered in test/forge*.test.sh. So the facts step now pins
CEREMONY_FORGE=github, the backend its stub is shaped for, and the stub
returns the array shape the new label read expects.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two failures on !193's first run, both real and both caught by the guards
that exist for them.
release-exercise: `lib/forge-forgejo.sh: line 550: REPO: unbound variable`.
The forgejo backend addresses the repository through REPO, which each
reconciler sets for itself; the github backend reads GITHUB_REPOSITORY
directly. facts.sh set neither, so every forgejo read refused — correctly,
and with the new #191 diagnostic, which is how it was legible at all. The
github-path suites could not have caught this: they never touch that
backend.
self-guards: changelog-armed measured a 404-character entry against the
300-character bound (#167). Split into three shorter entries in the same
fragment, which is what the rule asks for.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 0.4.1 drill measured both doors dead on Forgejo. lib/facts.sh gathered
`released` with `gh release view` and `labeled` with `gh api .../pulls`, and
release.yml tagged and published with `gh` — none of which exist on the
runner image. The merge door therefore read labeled=no for a correctly
labeled, correctly merged ceremony PR and refused it as "a bare push";
the tag door cleared every gate and died at `gh release create`.
Both are ported onto lib/forge.sh. Two asymmetries were measured against
the live instance and its swagger rather than assumed:
* GitHub serves an ARRAY of PRs at /commits/{sha}/pulls; Forgejo serves a
single OBJECT at /commits/{sha}/pull and 404s on the plural. Both verbs
emit the array shape, so facts.sh carries one jq expression.
* GitHub creates a tag by POSTing to /git/refs; Forgejo serves that path
GET-only and creates tags at /tags. A 1:1 port of the gh call would
have 404'd forever.
The behaviour change is the second half of the bug. Any failure used to
become a definite `no`, which is safe for row 4 and catastrophic for row 5:
it is how a missing binary became "this was not a release ceremony". Now a
completed read that finds nothing is still `no` and still fail-closed, and a
read that did not complete refuses and emits no fact at all.
Four new cases in test/facts.test.sh cover exactly that, and a mutation back
to the old fail-closed-on-error behaviour kills all four and nothing else.
1014 assertions, 22 suites, shellcheck and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 1 on #286: codex, claude and kimi each blocked on the same thing —
the amended paragraph carried incident narrative that CONTRIBUTING.md's
doctrine conventions keep in the cited record.
Drop #275's head sha and both start stamps, the cancel-in-progress
episode sentence, and the four clauses of 2026-07-27 gloss provenance.
Carry checks_state's carve-out as one clause with a bare (#139, #276).
The rule is unchanged and still decides #275's head unaided: two entries
for one check, newest by start time wins, and the cancelled one is not
the check's word while a non-cancelled sibling stands.
Step 1 ruled what a rollup entry means and never said which entry to
read. A job in a cancel-in-progress group displaces itself, so a head
routinely carries a CANCELLED node beside the SUCCESS that replaced it,
and read by class alone that head is not green while checks_state calls
it SUCCESS.
State the collapse ahead of the classes it feeds: newest entry by start
time, and a CANCELLED entry is not the check's word while a non-cancelled
sibling stands at the same head. All-cancelled and pending are untouched.
Refs #276
The drill ran and FAILED, and the record says so. Merge door: lib/facts.sh
reads the release label with `gh api`, the runner image has no gh, the fact
comes back "no", and decide fail-closes on a wrong fact — reproduced twice.
Tag door: clears every gate the merge door fails, then dies at
`gh release create`.
Release count in the scratch repo at the end: 0. Every refusal created
nothing, which is the property the drill exists to check.
drill-recorded wants a record, not a passing result — this is the honest
one, and it says 0.4.1 cannot publish from this instance until facts.sh and
the publish call sites are ported off gh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/VENDORED.txt and actions/docs-sync/docs-sync.sh entered the tree in
the same commit and are byte-identical at every tag — blobs 10c20a3c and
ba426479 at 0.1.0 through 0.5.0 — and 0.1.0's copy of the tool is already
manifest-driven (MANIFEST="docs/VENDORED.txt", L75). Citing 0.5.0 told the
0.1.0-0.4.1 tail, which is exactly the population this section is written
for, that the manifest was unavailable at its pin, so it would keep the
hardcoded list: #251's failure mode reproduced by the document that exists
to abolish it. The same file already said 0.1.0 at L131-L133.
Also make the guard's tracked-ness skip announce itself. It degrades to
"not asserted" wherever the tree is not a git work tree root, and doing
that in silence is the shape this script's own header argues against, so
the skip now prints on both output paths, green and red, with a test row
each way.
Round 1: claude blocking point, and claude nit 3.
Re-vendor tooling and docs-sync equivalents read the pin docs/VENDORED.txt
(available at 0.5.0 and later) instead of naming the doc set themselves, so
a new doctrine file reaches every consumer at its next ordinary pin bump
with zero list edits. A hardcoded list propagates nothing and its
staleness is silent: docs-sync --check asserts byte-identity for the files
the list names and says nothing about one it omits.
What makes reading the manifest sufficient rather than merely better is
the self-guard this PR adds, tagged unreleased until the first tag carries
it, per the RELEASES.md paragraph above it.
Refs #251
One CI step beside the self-ref pin, and test/vendored.test.sh covering
both directions: the manifest -> tree scan (missing, symlink, directory,
empty, ../ escape, absolute, untracked) and the closed-world root rule
(neither list, vendored, exempted, prose is not an input, no recursion
below the root), plus the real tree unmodified and the RELEASES.md
regression both ways.
The one-off `grep -Fx RELEASES.md` row at test/docs-sync.test.sh is
deleted (#251 D4): two spellings of "the manifest is right" is the drift
the manifest exists to prevent. Its intent is now a guard case, which the
next doctrine file inherits for free.
Refs #251
docs/VENDORED.txt is already machine-authoritative on the consumer side —
actions/docs-sync reads it as the sole declaration and enforces
"manifest union .ceremony/, nothing else". Nothing enforced the other end:
a doctrine file landing at ceremony every root with nobody adding it to
the manifest is a SILENT miss, because docs-sync only asserts
byte-identity for the files the manifest names.
Two directions, two mechanisms (#251 D2): manifest -> tree is a scan
(regular, non-empty, tracked, no symlink, no directory, no .. escape);
tree -> manifest is a closed-world rule over root *.md with a short
in-script exemption list, because nothing in the tree answers "which
files are vendorable".
Refs #251
The three stamps, in one commit as the ceremony requires: VERSION goes
bare, the self-ref pin follows it, and the changelog section is the
assembler's output rather than a hand edit.
0.4.1 is the forge release. Everything in the section comes from #188's
single fragment: ceremony stops being gh-only. `lib/forge.sh` selects a
backend from the runner's own environment, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` implement one call surface twice, and the
reconcilers preflight before they sweep — so a GitHub-shaped client
pointed at a Forgejo instance is a named refusal instead of a sweep that
reads nothing and reports success.
Verified on this instance before stamping: the first post-merge `labels`
run on main (7fc9afe, task 467) came back SUCCESS — the reconciler's
first green run on Forgejo, and the evidence the section's claims are
not merely asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ruled-term paragraph now names its field — a check carrying a
terminal conclusion is green or not-green by that conclusion whatever
its status reports — and rules the head with no checks configured:
nothing to wait for, request straight away, no argued exception owed.
The draft-round restatement comes out so the file states the rule once.
Closes#260, closes#272 via the PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"no activity for N days" was true of the old clock and is now imprecise:
an assignment no longer counts, so an item assigned yesterday would read
a nudge claiming nine days of nothing. It says "no comment" instead —
the fact the sweep actually read.
Refs #254
"a comment, a review or a commit" is the ruling nudge's house phrasing and
false on the issue surface twice over: there is no review or commit fact in
what the sweep reads, and an assignment is no longer counted here. Say
what is read, and say what does not buy another 7 days of silence.
Refs #254