Commit graph

74 commits

Author SHA1 Message Date
clad2
ceaf66bd13 docs(labels): claim the measured invariant, not an unmeasured Forgejo defect
The evidence shows the called workflow did not see the caller's event inputs
on THIS instance while the top level received them (runs 459/523 vs probe
6/7); it does not show what GitHub or a declared-and-passed input does, so
every prose site now states declare-and-pass as the measured-reliable channel
rather than attributing a drop to Forgejo (@codex-reviewer-andresmgsl, !218
blocker 2).

Refs #215
2026-08-05 21:18:29 +00:00
clad2
baa683211e fix(labels): pass bootstrap through the workflow_call boundary it was lost at
A called workflow cannot read the caller's dispatch inputs on this forge:
github.event.inputs is empty inside workflow_call even though the top-level
caller receives the value in both contexts (probe runs 6/7). The sweep's gate
read exactly that, so every dispatch-woken sweep bootstrapped — runs 459 and
523, ~20 label upserts per board event — while the trigger honestly logged
bootstrap=no.

The bridge, per the #6361 contract: labels-sweep.yml declares
workflow_call.inputs.bootstrap (string, default "no"); the dogfood caller and
the published CONSUMERS.md stub pass it via with.bootstrap with empty mapped
to "no" at the caller — kimi's edge: on schedule the top-level context is
empty, and an empty that slipped through would have turned every cron into a
bootstrap. The gate feeds the declared input to labels-reconcile unchanged,
so an invalid value meets the action's own yes|no refusal.

test/labels-bootstrap.test.sh pins every hop: the declared boundary, both
gates as the identity, no expression reading github.event.inputs (scoped to
${{ }} bodies — the file's prose names the context to explain it), the two
pass-throughs byte-exact, and the four value paths driven through the shipped
expressions into the action's real validator. Mutations: dropping the
declaration reds 4, dropping the pass-through reds 3, restoring the old gate
reds 2.

Refs #215
2026-08-05 21:06:29 +00:00
clad2
bae6f21b15 docs(runner-probes): the workflow-token record is comment 6262, not 6263
Off-by-one in the evidence anchor; a record that links the wrong comment is
a record that does not resolve (@codex-reviewer-andresmgsl, !216 round 2).

Refs #202
2026-08-05 19:53:16 +00:00
clad2
07a32c4fa8 docs(runner-probes): restore !207's release notes; per-run links; honest security lesson
Four corrections from @codex-reviewer-andresmgsl on 9c17a9e:
- changelog.d/202.md keeps !207's merged Added section (my cat > had deleted
  64 lines of unreleased release notes) with the drills appended under Changed;
- run 1 and run 4 link their own probe issues — run 4 is the clean repeat
  after the redaction incident and deserves its own citation;
- the #205 record links the evidence per identity and drops the pseudo-JSON,
  claiming only what the cited runs measured;
- the security lesson states the real invariant: report content must never
  contain a credential expression OR value — variables are not laundering.

Refs #202
2026-08-05 19:48:04 +00:00
clad2
9c17a9e4d8 docs(runner-probes): record the venue's first delivered drills
The owed-probes list keeps delivered probes with their probe-issue URLs: a
claim like 'the asymmetry reproduces' should carry a link a reader can open.
Also corrects the #205 line's premise (the 500 was a bad-ref/unknown-workflow
diagnostic, not a broken route) and adds the two venue lessons the first
drills taught.

Refs #202
2026-08-05 19:40:56 +00:00
clad2
768d54d1db Merge remote-tracking branch 'origin/main' into build/205-dispatch-rest 2026-08-05 17:19:10 +00:00
clad2
37e31ffd85 fix(labels): refuse an unset API root, name transport failures, update the docs
Three corrections from @codex-reviewer-andresmgsl's review of 935a813.

1. `api="${GITHUB_API_URL:-https://api.github.com}"` guessed GitHub when the
   variable was absent — driven with a recording curl, it reported success
   after POSTing to api.github.com from this forge. That is the "Never
   'probably github'" rule, and the same unset-environment refusal #201 just
   established for docs-sync. It now refuses before any request, and the test
   asserts zero calls were made: refusing after a POST is not refusing.

2. The "never silenced with || true" invariant was still asserted by grepping
   the gh line this port removed, so it passed on any REST implementation
   including one that swallows a failed POST. It is rebound behaviourally: a
   curl that dies at the transport must fail the step. Doing that revealed the
   step failed with a bare exit 7 and no sentence, so it now names the failure
   — owning the diagnostic is the whole point of the surrounding code.

3. docs/CONSUMERS.md and both caller comments still described `gh workflow
   run` as the mechanism. They describe the REST dispatch now, and the manual
   bootstrap command carries a forge-neutral curl form beside the gh one: a
   cross-forge runbook that sends this forge to a missing binary is wrong even
   where the prose around it is right.

Refs #205
2026-08-05 17:16:30 +00:00
clad2
65cee3fdf9 Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue 2026-08-05 15:54:29 +00:00
clad2
368621dcea docs(runner-probes): bind caller kind to its path class
The manifest target-validation added in 6874e76 checked owner and sha per
kind but never bound the kind to the path class, so a consistently swapped
caller layer passed: `<fork>/actions/x@<armed>` declared workflow_caller
satisfies the owner rebuild and the armed-sha test
(@codex-reviewer-andresmgsl, #202 review).

Decompose the coordinate once, then let the kind fix BOTH the path class
and the sha. Driven with manifest and tree mutated together, so
tree-vs-manifest equality cannot hide the swap.

Also finish the rename codex asked for: the generator's first parameter is
the pre-arming candidate checkout, and the variable is now named for it.

Refs #202
2026-08-05 15:33:20 +00:00
6874e76c04 docs(runner-probes): the checker binds the manifest to its target, and the snippets lint clean standalone (#202)
@codex-reviewer-andresmgsl, and he linted the published snippets DIRECTLY,
which my "parse, lint clean" claim had never meant.

1. THE CHECKER DID NOT CHECK THE TARGET. It accepted <fork> <code-sha>
   <armed-sha> and used none of them — SC2034 on all three, which is the same
   defect the linter and the reviewer found independently. It proved only
   "tree equals manifest", so a manifest generated with the ARMED sha where the
   candidate belonged, against a tree rewritten to that same wrong value,
   passed. Wrong-but-consistent is exactly what this gate exists to reject.

   Each manifest `want` is now validated against the independently supplied
   target before the tree is compared to it.

2. ONE ORDER, NOT TWO. Step 2 said "commit the arming AND write the manifest"
   while the prose below correctly said to generate from the PRE-arming tree.
   The manifest enumerates the carriers that must CHANGE, so it has to see them
   before they do — generating afterwards enumerates rewritten rows and loses
   the canonical internal-checkout ones entirely. The generator's first
   parameter is <candidate-checkout> now, and says so.

3. THE SNIPPETS LINT CLEAN STANDALONE. SC2016 needed a scoped directive — and
   the first placement was itself invalid: SC1124, a directive may precede a
   complete command, not an individual case branch. The checker's mktemp gets
   a trap.

Driven, the new controls:

  correct manifest + tree + target args        passes
  wrong fork, manifest AND tree consistent     refuses
  wrong candidate sha, consistent              refuses
  armed sha where the candidate belongs        refuses

plus every earlier class still red, and both snippets ShellCheck-clean when
extracted as an operator would copy them.

test/run.sh 28/28; repository shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 15:22:01 +00:00
fc24fa4b78 docs(upstream-sync): the inventory names docs-sync, which #201 makes forge-deciding (#200)
Found by combining all five open PRs and running the suite on the result —
which is the check this PR's own runbook adds, catching a real break the first
time it was applied at scale.

!203 (#201) makes actions/docs-sync/docs-sync.sh decide the forge from
GITHUB_SERVER_URL, because it was fetching the doctrine mirror from a
hard-coded github.com. This PR's guard requires every forge-deciding file to be
named in the inventory. Both are individually green; together the tree is red:

  forge-specific but not in docs/UPSTREAM-SYNC.md:
    actions/docs-sync/docs-sync.sh

The entry belongs here rather than in !203: the inventory is this PR's artifact,
and !203 is a bug fix that should not have to know about a guard absent from
its base. Adding it early is harmless — the guard checks that deciding files
ARE listed, not that listed files decide — and correct the moment both land.

Five-way combined tree after this: 28 test files 0 failed under the runner's
jq 1.6, shellcheck 0.10.0, actionlint, self-ref, marker, vendored and
changelog-armed all clean.

Refs #200
2026-08-05 15:15:35 +00:00
20f4b287f7 docs(runner-probes): the generator survives a one-layer probe, callers carry full coordinates, one domain (#202)
@codex-reviewer-andresmgsl drove the published commands again and found three.

1. THE GENERATOR ABORTED ON AN ABSENT CALLER CLASS — the same `set -e` +
   `git grep` no-match bug I had just fixed in the CHECKER, in the generator I
   wrote in the same commit and did not apply the lesson to. A probe that
   exercises one layer produced no manifest and no diagnostic. `|| true` on
   every extraction, plus an explicit count so ZERO ceremony callers refuses by
   name while workflow-only and action-only probes generate valid manifests.

   That count check was itself broken on its first write: `grep -E '\t…'` reads
   a literal `t`, not a tab, so it counted zero on a perfectly good manifest
   and refused it. Found by running it.

2. CALLERS CARRY THE COMPLETE COORDINATE. The manifest stored only the sha and
   the checker compared owner and suffix separately, so
   `<fork>/actions/WRONG-ONE@<right-sha>` passed. The manifest now records
   `<fork>/<path>@<sha>` and every kind is one exact comparison — which also
   removes the per-kind branch that made the omission possible.

3. GENERATOR AND CHECKER SHARE ONE DOMAIN. `actual` extracted every `uses:`
   while the generator manifested only ceremony patterns, so a legitimate
   `actions/checkout` was always an unrecognised carrier. Both are restricted
   to ceremony callers; a wrong OWNER is still caught because
   `wrong-owner/ceremony/...` is still a ceremony caller.

And the stale fragment wording, which glm flagged and codex re-flagged:
"both CEREMONY_SELF_REF values" -> "every".

DRIVEN, all of it:

  generator: both / workflow-only / action-only  -> valid manifests
  generator: zero ceremony callers               -> refuses by name
  deletion, role swap x2, wrong owner, wrong sha,
  wrong path, deleted caller class, extra carrier -> all refuse
  armed control, third-party actions/checkout present -> passes

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 15:07:11 +00:00
745944ec8c docs(runner-probes): the arming gate is a manifest comparison, driven against all six failure classes (#202)
@codex-reviewer-andresmgsl's four holes and @glm-reviewer-andresmgsl's prose
staleness. Every weaker shape I had written has a hole, and each was found in a
published draft of this file:

  "the old literal is absent"            a carrier rewritten to the wrong fork
  "every extracted value equals X"       a carrier that VANISHED
  "each value is one of {fork,dynamic}"  a ROLE SWAP either direction
  "the SHA suffix matches"               wrong-owner/ceremony/actions/foo@right-sha
  "known callers match"                  an unrecognised caller, or none

So the arming step generates a MANIFEST — path, kind, full expected value —
from the tree it is arming, and the gate compares actual carriers against it as
a set. All six become one kind of failure: the sets differ. Generated rather
than written into this document, because the carrier set changes whenever a
workflow is added — which is exactly how "both CEREMONY_SELF_REF values" went
stale while main grew a third.

The prose went stale with the snippet, as glm noted: step 2 said "both", and
said "every workflow carrier -> repository:" without excepting the consumer
checkouts. Both corrected.

DRIVEN, not asserted. I built an armed/probe pair and ran every class:

  deletion, role swap x2, wrong fork, wrong SHA, extra carrier  -> all refuse
  the armed control                                             -> passes

Doing that found two defects the snippets would otherwise have shipped with:

  * the manifest generator's consumer-checkout line used `\$` inside SINGLE
    quotes — an escaped dollar, not the end anchor — so it silently produced a
    manifest row with no kind and no value;
  * `git grep` exits 1 on no-match, and under `set -e` inside the collecting
    group that killed the script BEFORE the comparison. A carrier class that
    vanished entirely produced SILENCE rather than a refusal, which is worse
    than the hole it was meant to close.

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 14:53:30 +00:00
dc87051c69 docs(runner-probes): enumerate the real carriers, spare the consumer checkouts, and make the snippet run (#202)
@codex-reviewer-andresmgsl's three, all verified against current main before
fixing.

1. THERE ARE THREE SELF-REF CARRIERS, NOT TWO — labels-sweep.yml:52,
   labels.yml:51, release.yml:132. My `[ "$n" -eq 2 ]` came from the
   pre-upstream tree, so it would have REJECTED a correctly armed candidate and
   told the operator to rewrite two of three, leaving one workflow pinned to
   the tag. The gate enumerates from the tree now, with the derivation commands
   beside the table so the list is re-checked rather than trusted.

2. NOT EVERY `repository:` BELONGS TO THE FORK. Three are
   `${{ github.repository }}` — labels-sweep.yml:69, labels.yml:92,
   release-exercise.yml:72 — and they fetch the CALLER's repository. My loop
   required every one to equal the fork, which would have rewritten the
   consumer checkouts and quietly changed what the probe exercises. Internal
   self-checkouts (four) are asserted to be the fork; consumer checkouts are
   asserted to stay dynamic.

3. EACH CHECK IS BOUND TO THE TREE IT IS ABOUT — `git -C "$armed"` for the
   carriers, `git -C "$probe"` for the callers, instead of depending on the
   operator's current directory. And `mapfile` rather than `git grep | while …
   fail`: the loop ran in a pipeline subshell, so `fail` exited the subshell
   and the gate carried on. Collect first, validate after, under a declared
   `set -euo pipefail`.

And the snippet is now executable rather than illustrative: placeholders became
positional parameters, so it parses, is shellcheck-clean, and runs. Driven
against the unarmed tree it refuses with `CEREMONY_SELF_REF=0.6.0` — a tag
rather than the candidate SHA, which is exactly the case it exists to catch.
Publishing a gate that could not run would have been the same defect one level
up.

Branch updated from merged main (e236318). test/run.sh 28/28; shellcheck 0.10.0
and changelog-armed clean.

Refs #202
2026-08-05 14:13:40 +00:00
7e02344672 docs(runner-probes): the arming gate asserts what each carrier IS, not that a literal is gone (#202)
@codex-reviewer-andresmgsl: absence of the canonical coordinate is not proof of
correct arming. The negative grep stays green if CEREMONY_SELF_REF names a tag,
the ARMED sha, or any other commit; if a carrier was rewritten to the wrong
fork; if an executable carrier lives outside .github; or if a carrier simply
disappeared rather than being rewritten.

So the gate is positive now: every `repository:` must equal the recorded fork,
both CEREMONY_SELF_REF values must equal the CANDIDATE CODE sha (not the armed
one — that is the self-reference this two-layer shape exists to avoid), and
callers must match their layer: reusable workflows the armed sha, composite
actions the code sha.

With a COUNT beside the comparison. `n -eq 2` is the part that catches a
carrier which vanished, which a per-value loop cannot see — the same shape as
counting the call sites a pin is guarding rather than only checking the ones
that are there.

The canonical-coordinate grep stays as a cheap extra rather than as the proof.

Wording, same review: steps 1 and 2 advance the tip of ONE fork branch, so
reset removes that branch, not "candidate and armed branches".

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 14:02:08 +00:00
f3a1336d42 fix(upstream-delta): discovery derives from the tree, not from a glob list (#200)
@codex-reviewer-andresmgsl did not argue this one, he reproduced it: an
`actions/*/action.yml` declaring CEREMONY_FORGE_CLIENT and a workflow written
`.yaml` rather than `.yml`, both invisible to the hand-picked globs, guard
still 21/21 green.

The first is not an edge case — `actions/*/action.yml` is this repository's
normal composite structure and a client declaration there IS a forge decision.
The second shows `*.yml` was never a complete workflow surface.

So discovery walks the tree and EXCLUDES by class rather than enumerating
directories, depths and extensions. Excluding is the safer default: a new file
type arrives scanned rather than invisible. Out of scope are .git/, test/
(whose harness asserts these tokens by design), changelog.d/ and *.md — prose,
including drills/, which stays in the inventory because its records are
forge-specific by CONTENT while a record mentioning a selector verb in prose is
not a decision.

Both of his reproductions are now fixtures driving the real no_unlisted, and
restricting discovery back to *.sh reds four cases.

The documentation claim is aligned with what the guard does rather than what
the table implies: it checks forge DECISIONS in executable and configuration
files; it is not a diff against upstream, so drills/ and labels.conf are listed
by judgement rather than found by scan. Saying otherwise made labels.conf and
drills/ look like evidence of completeness while action.yml was invisible.

upstream-delta 24/24; test/run.sh 29/29; shellcheck 0.10.0, actionlint and
changelog-armed clean.

Refs #200
2026-08-05 13:58:32 +00:00
e27acd8ab9 docs(runner-probes): arming is two layers, because a commit cannot contain its own SHA (#202)
@codex-reviewer-andresmgsl found that the procedure was not executable as
written, and the reason is structural rather than a wording slip.

The candidate's workflows carry `repository: heavy-duty/ceremony` beside
`ref: ${{ env.CEREMONY_SELF_REF }}`, so arming must rewrite them. But
rewriting CREATES A NEW COMMIT, and a commit cannot embed its own object ID. So
a single-layer arming is self-referential: pin the callers to the pre-rewrite
SHA and they load the UNARMED workflows; pin them to the post-rewrite SHA and
you are asking that commit to contain itself. My step 3 asked for exactly that.

Two layers, stated as a table because the distinction is the whole thing:

  candidate code SHA   the immutable tree under test — actions/, lib/
  armed workflow SHA   a child commit whose workflows point at the fork and
                       whose CEREMONY_SELF_REF is the candidate code SHA

And callers pin by layer, because they are not the same thing: composite
actions to the candidate code SHA, reusable workflows to the armed SHA, which
is the only revision whose inner checkout is rewritten.

The completeness check becomes a mechanical non-zero gate — `git grep` for
executable `uses:`/`repository:` carriers over the ARMED tree, exiting non-zero
on any hit — rather than "every remaining hit must be prose". A partial rewrite
does not announce itself: it silently tests canonical main.

The result issue records both SHAs, not one, or a later reader cannot tell
which tree answered.

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 13:54:44 +00:00
634e7a3528 fix(upstream-delta): the object is mandatory, the scan covers every governed surface, and the teeth drive the real guard (#200)
@codex-reviewer-andresmgsl's five points. Three were correctness, and one of
them found that my must-fail cases could not fail.

1. THE OBJECT IS MANDATORY. UNVERIFIABLE-HERE is gone: a missing ref, an absent
   object and a non-ancestor are three distinct refusals. The ref is now the
   FULL 40-character SHA, and ci.yml fetches exactly that object before the
   suite. "Runs offline" means the TEST reads local evidence; it never meant CI
   may omit the evidence and pass.

2. THE SCAN COVERS WHAT THE INVENTORY CLAIMS. It walked shell under four globs
   and never looked at workflows, .github/labels.conf or drills/ — three
   categories the inventory governs. Widened, and it immediately found four
   real blind spots on merged main: refs-not-closing's declaration,
   labels.yml's inline forge decision, refs-guard.yml's GitHub-only scheduling
   and release-exercise.yml's pinned CEREMONY_FORGE. All four are now inventory
   entries with the issue that removes them, because a delta location with no
   exit is indistinguishable from one nobody noticed. A file that DECLARES a
   client is no longer exempt as a "consumer" — only files that merely CALL the
   shim are.

3. THE TEETH NOW DRIVE THE GUARD. They asserted the predicates separately and
   never invoked no_unlisted, so the guard could have been `return 0` and both
   must-fail rows would still have passed. SCAN_ROOT is a parameter now and the
   cases build a tree, add an unlisted decider — shell AND workflow, so
   coverage cannot regress to the old glob — and assert the real top-level
   check fails naming it. Replacing no_unlisted with `return 0` reds five.

4. PATH MATCHING, NOT PREFIX MATCHING. `drills/` accepted `drills-old/x` and
   `lib/forge.sh` accepted `lib/forge.sh.backup`. Exact for files, `dir/` for
   directories, with both negative boundaries covered.

5. THE IMMUTABLE SHA IS CAPTURED AT FETCH. The runbook now takes
   upstream_sha=$(git rev-parse gh/main) once and merges and records that
   value. This is not hypothetical: while this PR was in review upstream moved
   from 8c3a4d1 to 08e2912, and re-reading gh/main at recording time would have
   written a commit this tree does not contain. I caught that by walking into
   it.

test/run.sh 29/29; upstream-delta 21/21; shellcheck 0.10.0, actionlint,
changelog-armed clean.

Refs #200
2026-08-05 13:51:21 +00:00
b80767e36c docs(runner-probes): rewrite coordinates not only refs, keep result issues, and stop asserting what was not measured (#202)
@codex-reviewer-andresmgsl's three operational corrections.

1. ARMING REWRITES THE COORDINATE. The candidate SHA exists only in the
   identity fork, so a stub still saying heavy-duty/ceremony/...@<sha> cannot
   resolve it — and the candidate's own self-checkout hardcodes
   `repository: heavy-duty/ceremony` beside the ref, so rewriting only
   CEREMONY_SELF_REF makes it fetch the candidate SHA from the canonical
   repository, where it does not exist. Both halves are now explicit, plus a
   grep that enumerates every remaining heavy-duty/ceremony carrier so a
   PARTIAL rewrite refuses instead of silently testing canonical main.

2. RESULT ISSUES ARE NOT RESET SCOPE. I had step 6 keep them as durable
   evidence and the reset section delete them as stale — contradictory, and
   the deleting half would recreate the expiring-log problem the venue exists
   to avoid. Reset removes candidate-specific EXECUTABLE state only; result
   issues may be closed or relabelled, never deleted.

3. NO UNMEASURED CLAIMS. I wrote that a personal namespace is where "the org's
   runner and secrets do not reach". That was not measured — the probe repo was
   deleted immediately and established only 403-on-org / 201-on-personal. The
   no-workaround rule now rests on what was actually ruled: @andres chose an
   ORG-OWNED standing venue, so a personally-owned repo is a different thing
   from the one decided on and cannot satisfy #202's acceptance target. If
   runner reach matters, it gets measured once the venue exists.

test/run.sh 28/28; shellcheck 0.10.0, changelog-armed clean.

Refs #202
2026-08-05 13:46:03 +00:00
f3f7538d15 docs(upstream-sync): stale in-flight branches, and auditing post-merge runs by executed steps (#200)
@codex-reviewer-andresmgsl's two additions (#5697), both measured in the #198
sync rather than anticipated.

Every branch open across a sync is stale afterwards: Forgejo never re-tests an
open PR when main moves under it, so #206 and #207's green 22-file suites were
about a tree that no longer existed once the 28-file one landed — and #206's
fragment was individually green while making the combined tree red under a rule
the sync itself introduces. The runbook now says to update each in-flight
branch from the newly synced main, or check them in a scratch merge, and that a
prior approval is evidence about the tree it was given on.

And post-merge runs are audited by executed steps, never by colour: inventory
what the sync changed about triggers and jobs, read which job actually ran, and
treat a green refusal path as evidence for that path only. Run 326 was green
and had reconciled nothing.

Both failures happened with the no-runtime-gh guard green and CI green, so the
runbook says that too.

Refs #200
2026-08-05 13:42:21 +00:00
e61bb91476 docs(runner-probes): its own document, an arming procedure, and the evidence boundary made consistent (#202)
@codex-reviewer-andresmgsl's four gaps.

1. BRANCH UPDATED TO CURRENT MAIN. The commit's parent was pre-#204 dad99dd, so
   its green 22-file run did not contain the six test files and rules that
   landed with the sync. Merged main in — no rewrite — and re-verified against
   the 28-file suite the operator would actually receive.

2. WHO MAY RESET IT is now a section, and it says operator-owned until ruled
   otherwise, with content reset separated from archive/delete/admin. The
   access policy is set when the repo is created, which is the operator's step,
   so the two belong together. Flagged for @andres rather than assumed.

3. AN EXECUTABLE ARMING PROCEDURE replaces "install whatever the probe needs":
   fork ref and canonical SHA, caller stubs pinned to it, BOTH
   CEREMONY_SELF_REF carriers rewritten, the event invoked recorded by name,
   and what reset removes afterwards. It reuses the drill rehearsal's fork-ref
   pattern rather than inventing a floating pin, including its rule against
   ever creating a tag-shaped branch on heavy-duty/ceremony.

4. THE EVIDENCE CONTRADICTION IS RESOLVED. "Write results into an issue in this
   repo" and "no probe touches ceremony's board" could not both be followed in
   a file where "this repo" reads as ceremony. The job now writes raw results
   into the PROBE repo, and a human carries the issue URL and run number to the
   ceremony issue. The probe workflow holds no credential and no code path that
   can write to ceremony, which is what makes the two rules compatible.

Placement: the operational contract moves to docs/RUNNER-PROBES.md, with a
short cross-link in drills/README.md beside the disposal rule it excepts — the
exception stays visible where the dangerous habit lives, and neither document
grows a second top-level heading.

test/run.sh 28/28 on the updated branch; shellcheck 0.10.0, actionlint,
self-ref, marker, vendored and changelog-armed clean.

Refs #202
2026-08-05 13:40:28 +00:00
e965b15cbf docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#200)
The third child of #197, written immediately after performing the sync it
describes, while the findings are still first-hand.

docs/UPSTREAM-SYNC.md carries the procedure and the six standing resolutions,
each with the issue that decided it, so they are not re-argued every sync. The
parts that are not obvious from the outside, and that the 0.6.0 sync paid to
learn:

  * THE AUDIT STEP. `git merge` takes upstream's side wherever only upstream
    moved a region, so a function upstream ADDED to a file this tree owns
    arrives with no conflict and no question. Reviewing the hunks cannot find
    it — four reviewers read the same diff and each found a different subset.
    That was eight runtime `gh` call sites in three files and two file types.

  * THE SAME MECHANIC APPLIES TO STATE. A resolved region can remove a producer
    whose consumers auto-merged, and those consumers degrade to empty rather
    than erroring, so nothing goes red. Three such seams in one sync.

  * VERIFY WHERE IT RUNS. "Green locally" was wrong three times, for three
    different reasons: shellcheck-all lints TRACKED files so a new file's first
    lint is meaningless; CI pins shellcheck 0.10.0; and the runner's jq 1.6
    exits 0 where 1.7 exits 4 on `jq -e` with empty input — which was not a
    test problem but a guard accepting an unreadable read.

  * TEST THE MERGE RESULT. Forgejo tests heads, never what two branches produce
    together, and two green PRs did produce a red tree in this sync.

  * AFTER MERGING, CHECK THE SWEEP RECONCILED SOMETHING. The first post-merge
    run was green and had done nothing.

.upstream-ref records the carried commit in machine-readable form beside the
CHANGELOG's prose. test/upstream-delta.test.sh asserts every forge-DECIDING
file is named in the inventory — offline, comment-aware, and refusing rather
than skipping when the ref is missing. Shim CONSUMERS are allowed by name, so
a seventh consumer is silent and a seventh decider is not.

docs/CONSUMERS.md now states that two ceremonies answer to the same version
number and how a consumer says which one it pinned.

Must-fail, both from the issue's test plan: scattering a forge_detect branch
into an unlisted file reds the guard; blanking .upstream-ref reds it too.

test/run.sh 29 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0,
actionlint, self-ref, marker, vendored and changelog-armed all clean.

Refs #200
2026-08-05 13:36:17 +00:00
e035130f65 merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.

The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.

The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.

test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.

Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.

The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.

actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.

test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.

Refs #198
2026-08-05 11:56:23 +00:00
cndgrr
fb8f8282a9 release: cut 0.6.0
Thirty-five fragments assembled into '## 0.6.0 — 2026-08-05'; VERSION to
bare 0.6.0; the three CEREMONY_SELF_REF carriers stamped "0.6.0" in this
one commit; the three docs/CONSUMERS.md availability markers cleared to
name 0.6.0 — refs-not-closing (#218), the RELEASES.md mirror entry (#248)
and the vendored-manifest completeness guarantee (#251).

drills/0.6.0.md opens with the measurement that decides its shape: the
doors-unchanged conditions do NOT all hold at this candidate, because
lib/changelog.sh moved on the release path since the last rehearsed tag
0.4.0. A full disposable-repo rehearsal is owed and is in progress; the
record is committed early and filled from the runs as they happen.

Refs #249.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 00:01:03 +00:00
ca99182e80 fix(forge): percent-encode asset names, and stop the docs naming a client
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>
2026-08-04 12:11:06 +00:00
cndgrr
42b8fb6850 fix(consumers): the manifest is readable at 0.1.0, not 0.5.0
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.
2026-08-04 10:29:44 +00:00
cndgrr
b9eedd8973 docs(consumers): cite the manifest availability to the tag that shipped it
0.5.0 availability is actions/docs-sync own arrival (#19); #251 is the
guidance and the guarantee, not the file date.

Refs #251
2026-08-04 10:18:17 +00:00
cndgrr
7909383ca0 docs(consumers): read the pin manifest, never a copy of it
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
2026-08-04 10:18:17 +00:00
Andriujose
2a1f8a012b test: pin inline marker mention boundary 2026-08-03 23:05:05 +00:00
Andriujose
d87d76d64b fix: align marker guard with release oracle 2026-08-03 22:56:02 +00:00
Andriujose
7200c8da49 docs: define traceable availability markers 2026-08-03 22:51:17 +00:00
Andriujose
66b136efc0 docs: wire refs guard into ceremony flow 2026-08-03 20:31:34 +00:00
Andriujose
8c29424519 docs: correct release gate mechanics 2026-08-03 18:26:58 +00:00
Andriujose
ab60709f49 docs: add release-management doctrine 2026-08-03 17:29:15 +00:00
dan-claude-bot
e5e7555184 release: cut 0.5.0
Five fragments assembled into '## 0.5.0 — 2026-08-03' (#205 #216 #221
#224 #226); VERSION to bare 0.5.0; the three CEREMONY_SELF_REF carriers
stamped "0.5.0" in this one commit; the panel-rows unreleased marker in
docs/CONSUMERS.md cleared to name 0.5.0; drills/0.5.0.md records the
doors-unchanged ruling with the measurements as they are at 0ac3a6f.

Refs #233.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:01:05 +00:00
f2d5fcd565 feat(forge): derive outstanding review requests from the head, not the field
Term 4. GitHub clears requested_reviewers when a verdict lands, so the field
answers "who still owes a verdict" by itself. Forgejo never clears it —
measured: rig!140 listed all three panelists with all three verdicts in, and
rig!146 still lists three while MERGED, so the field is stale even on a
closed PR.

Read raw on Forgejo that is not a cosmetic over-count. `requested` drives
three decisions, and a permanently-true field pins a PR at
state:bots-reviewing for life and stops blocker:unrequested from ever being
true: the sweep believes a round is live forever and no staleness can
correct it.

So the requested set is intersected with who has NOT submitted a verdict for
the current head, derived from /pulls/{n}/reviews — the read that is true on
both forges. On GitHub the filter removes nothing, because the field is
already accurate; term 5 holds by construction rather than by care.

A STALE approval — an approval of an older head — still owes a verdict. That
is the case that matters: treating it as answered would let a stale round
read as complete, which is the shape #136 exists to prevent.

Mutation-verified both ways: reading the field raw again reds three cases,
and treating STALE as answered reds two.

Also documents @grok-reviewer-andresmgsl's ask (#4763): every panel= account
must be able to read the repo, or the forge refuses the review request —
422 naming the account on Forgejo. A real failure mode for private
consumers, and it fails loudly rather than sweeping blind.

Refs #188
2026-08-02 19:48:09 +00:00
dan-claude-bot
44b1a3d23c fix: a draft never reads state:needs-human — round 1, claude
The reorder let a draft with a live human request plus a standing block
or comment fall through to round_state, whose human-request precedence
sits above BLOCK/FEEDBACK — 224 of claude's 1500 fixture cases read
needs-human on a PR GitHub cannot merge. decide_state now disqualifies
needs-human unconditionally under DRAFT=true, landing on
state:addressing like the blocker/needs-ruling/blocked clauses. The two
new rows assert the criterion where it can actually fail: human
requested x {CHANGES_REQUESTED, COMMENTED}. Also grok's nit: the
bootstrap row for state:building now matches LABELS.md (draft is
evidence, not the definition), and the CONSUMERS.md reflow nits are in.

Refs #205

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:13:25 +00:00
dan-claude-bot
ecb0371cad docs: clear five stale unreleased markers; the release PR owns clearing
Each marker now says available-at-tag in the guide's existing L420
phrasing, verified by tag containment in #221; every never-mix-refs
sentence survives verbatim. The convention paragraph gains its missing
half: the release PR that ships machinery clears, in that same PR,
every marker its assembled section makes false.

Refs #221

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:32:31 +00:00
dan-claude-bot
8db6c3ae29 feat: per-author review panels — labels.conf gains panel[<login>]= rows
One resolution point (panel_for_author) feeds set_required_bots; the
author's row when the conf defines one, the base panel= otherwise, minus
the author in either case. Bracket prefixes are matched quoted so the
case patterns cannot glob (D7, panela= tripwire). configured_label_rows
skips the rows so a dispatch bootstrap cannot mint a label named after
one. BUILDER.md/REVIEWER.md carry the one D9 wording; CONSUMERS.md
publishes the row as unreleased with the parse-failure warning.

Refs #224

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:29:15 +00:00
dan-claude-bot
be660358f2 docs: spell out crew's four-edit migration; fold in crew#250 field facts (#209)
crew#250 verified two facts the design prose now carries: a queue-
displaced run is not independently rerunnable (gh run rerun / --failed /
--job all refuse), so a victim PR had no manual escape hatch; and the
displacing burst is deterministic — one review_requested event per
panelist per request — so displacement is the steady state of a working
fleet, scaling with panel size, not a traffic spike.

CONSUMERS.md now walks the adoption as one atomic four-edit PR with crew
as the worked example: the pin bump in every ceremony uses: reference,
the new labels-sweep.yml caller, the cron RELOCATED (bold warning: a
copied-not-moved schedule double-fires sweeps into the one shared group
and reads as the bug getting worse after the fix), and actions: write
replacing the labels caller's actions: read.

Refs #209

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:25:26 +00:00
dan-claude-bot
45aa806207 labels: detach the reconcile sweep from PR-triggered runs (#209)
The sweep rode the same workflow run as the PR event that woke it, so
every displacement in the shared labels-reconcile queue recorded a
CANCELLED reconcile check on some PR — fake red CI that held review
requests. The reconcile + issueflow jobs move, unchanged, to a new
reusable labels-sweep.yml behind their own caller; labels.yml gains a
trigger job that dispatches the consumer's sweep caller with the plain
GITHUB_TOKEN (workflow_dispatch is a documented no-retrigger exemption)
on every event that used to run reconcile. A displaced sweep now cancels
on the Actions tab, attached to no PR; PR checks show scope + trigger.

Because every trigger-driven wake arrives as workflow_dispatch, the event
name alone no longer separates the operator's manual bootstrap from an
event-woken sweep: the sweep caller's bootstrap dispatch input does — the
trigger passes no, a bare manual dispatch defaults to yes. The sweep
reusable also takes pr_workflow_name, exported as SELF_WORKFLOW for the
#208 reconciler (harmless to earlier ones; zero file overlap with #208).

The trigger is deliberately loud: a pin bumped without the sweep caller,
its bootstrap input, or actions: write on the labels caller goes red at
the trigger job instead of silently never sweeping again — documented in
docs/CONSUMERS.md with the split stubs and the atomic-adoption note.

Refs #209

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:20:00 +00:00
codex-bot-andresmgsl
4198597834 docs: correct labels maintenance cadence 2026-07-29 10:37:03 +00:00
codex-bot-andresmgsl
3eff28e624 docs: explain sweep cadence and manual dispatch 2026-07-29 09:27:29 +00:00
claude-bot-andresmgsl
8cf3c335df labels: name what the hourly cron uniquely covers (#199 round)
The trigger comments, CONSUMERS stub, changelog, and reusable labels.yml
comment said "events carry every real state change in seconds; the cron
only backstops a forgotten handoff." That is backwards: no subscribed
event wakes this sweep for a review verdict landing (no pull_request_review
trigger), blocker:ci-red set/cleared, blocker:conflict when another PR
merges, or the time-based stale/48h-reclaim. The hourly cron is the sole
discovery path for those four classes, not a forgotten-handoff net —
so the comments now name them and warn against deleting the cron (AC5).

codex-2 / kimi-2 (both blocking, round 2 @0a812c4b).
2026-07-28 19:26:40 +00:00
claude-bot-andresmgsl
0a812c4b19 labels: keep edited/reopened on issues; correct 0.3.0 adoption prose (#199 round)
Round fixes on #200.

codex-1 (blocking): the issues narrowing dropped `edited`/`reopened`, but both
carry a queue-state change an event uniquely carries — `edited` a body rewrite
of the `Blocked by #N` declaration the sweep parses
(issueflow-reconcile.sh:179), `reopened` a closed issue re-entering the queue.
Dropping them tripped #199's must-fail. Narrow to
`[opened, closed, edited, reopened]`, dropping only the churn/validation
actions labeled/unlabeled/assigned/unassigned. Trigger tests now pin
edited/reopened present and the four dropped; labels.test.sh exact-list updated.

kimi (blocking): the "supersedes unreleased #144" prose was false — #144's
edited/reopened shipped in 0.3.0. Dissolved: we now keep them. CONSUMERS prose
rewritten to the real version history (0.2.0 #32 / 0.3.0 #144 / #199 narrows),
and the #137 review-request line corrected from "unreleased" to shipped-in-0.3.0.

kimi (non-blocking): reusable labels.yml comment no longer cites */15.

codex-2 (AC1 after-measurement / closing) escalated to triage on #199 — held,
not guessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 19:04:27 +00:00
claude-bot-andresmgsl
12887cc9de docs: narrow CONSUMERS stub issues triggers, relax cron to hourly (#199)
Bring the published caller stub to the #199 shape in lockstep with
self-labels.yml (preserving the stub<->caller parity #144 established):
hourly cron, issues: [opened, closed], per-trigger why-comments. Rewrite
the adoption prose — 0.2.0 shipped the broad set; #199 narrows it at its
pin bump and supersedes the unreleased edited/reopened additions (#144).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:13:15 +00:00
claude-bot-andresmgsl
be666ebed6 feat: ceremony flips to grouped — changelog.d/shape = grouped, five fragments converted, docs per D6
167/175 gain '### Added', 173/178 '### Changed', 180 '### Fixed' — every
bullet byte-identical, headings only (the #158 bar, inverted). CONSUMERS.md
names the sentinel and the flip procedure; changelog.d/README.md names the
sentinel. 182.md is this PR's own fragment, grouped atop the sentinel it
ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:14:05 +00:00
codex-bot-andresmgsl
eae000bd62 docs: add actions read to private caller guidance 2026-07-24 22:57:22 +00:00
claude-bot-andresmgsl
0b77d4b860 fix: the CONSUMERS.md stub's issues: types match the caller's, parity-tested
The stub published [opened, labeled, unlabeled, assigned, unassigned,
closed] while ceremony's own caller listens on eight types — PR #32's
70db91f widened the caller by edited and reopened and the stub never
followed. Both are load-bearing: an edited body rewrites the Blocked-by
declaration the reconcile sweep parses, and a reopened issue re-enters
the queue wearing labels derived at close (#144).

The stub's list is now byte-identical to the caller's, a parity row in
test/labels.test.sh keeps it that way (red on a dropped type, a drift,
or a reorder in one file only), and one adoption note names the tag the
widened list rides in on. The caller does not narrow.

Closes #144

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:43:35 +00:00
Daniel Marin
6a16a30bab
Merge pull request #143 from claude-bot-andresmgsl/build/137-review-request-wake
fix: review_requested wakes the labels sweep — blocker:unrequested clears when the ask lands
2026-07-24 14:33:11 +01:00