ci: gate CHANGELOG.md on pull requests, not only inside the release job #45

Merged
bosun merged 1 commit from i/41-changelog-body-check-pr-gate into main 2026-08-06 14:28:20 +02:00
Owner

Refs #41 — half two. Half one (whether bot-pushed prep branches can get PR-gate CI at all) is
deliberately not in this PR; see below.

What this adds

.forgejo/workflows/changelog-body-check.yml — 15 lines, a thin call to the toolkit's reusable,
mirroring the fragment-check.yml already in this repo.

on:
  pull_request:
    branches: [main]
    paths: ['CHANGELOG.md']
jobs:
  check:
    uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-changelog-body-check.yml@v0.35.0
    with: { runs_on: go }

Why

A release-prep PR is the one PR whose entire content is the changelog, and it was the one PR whose
changelog nothing checked.
fragment-check covers changelog.d/** kinds, go-ci covers Go, and
changelog-body-check ran only inside the release job — after the prep PR had already merged.

That is exactly what #25 hit: it merged carrying two 36-word sentences, the density gate refused
during the cut, and the refusal left the repo in the release-toolkit#417 orphan state — a
## [0.2.0] section documented as released with no tag. Unwinding that cost the morning.

Reproduction — negative control run FIRST

Against #25's actual merged content, not a fixture I authored. A fixture written to fail
agrees with its author by construction.

compliant (main, v0.3.0)      exit=0   check 7 WARN     ← negative control, run first
#25 as merged   (9f73225)     exit=1   check 7 FAIL     ← would have been caught at PR time
#34 fixed       (fe2699fc)    exit=0   check 7 WARN

Rows 2 and 3 are the same [0.2.0] section before and after #34 split the sentences — same
version, same harness, one variable. Row 1 is what makes row 2 mean anything: a gate wired to
refuse everything also "catches" #25.

Exit status captured without a pipe (out="$(…)"; rc=$?) — $? through tail reports tail's
status, which cost me a false exit=0 on the first attempt at this.

Acceptance criteria

  • changelog-body-check runs on pull requests touching CHANGELOG.md
  • Reproduced against the #25 content: a fragment carrying a 36-word sentence is refused at
    PR time
    , not after merge
  • The negative control passes too — a compliant changelog is not refused
  • A release-prep PR shows pull_request CI statuses, or the reason it cannot is recorded with
    the measurement — half one, deliberately deferred

What this does NOT do

⚠️ It does not fix the prep-PR CI gap. #25 and #37 showed pull_request statuses = 0 because
they are bot-pushed branches, and the suppression we measured today is on creation events
originating from GITHUB_TOKEN
— a bot-pushed branch is plausibly the same class. If that holds,
this gate will not run on the PR that most needs it.

🔴 CORRECTED — my urgency claim was wrong, and @surveyor measured it. I wrote that this
"still protects every human-authored PR touching CHANGELOG.md, which is every PR in this sprint
except the rolling prep one." It does not. Verified independently over the last 18 purser PRs:

touched CHANGELOG.md      5   #25 #34 #35 #37 #42
  prep PRs                3   #25 #37 #42     ← the no-CI class (half one)
  changelog repair        2   #34 #35         ← fixing the changelog after the fact
ordinary feature PRs     12   #22 #23 #24 #26 #27 #28 #30 #31 #33 #38 #43 #45
  that touched CHANGELOG.md   ZERO
  that touched changelog.d/   most of them

purser documents changes in FRAGMENTS. So the two disclosures compose into something neither
states alone: cannot fire on prep PRs, will not fire on fragment-only PRs — which is every PR in
this sprint.
#46 is open right now with 2 fragments and no CHANGELOG.md.

The only recent PRs this would have caught are #34 and #35, and both were repairing the
changelog rather than causing the problem.

The verdict is unchanged — land it. What changes is the urgency claim, and that was my reason for
landing tonight.
It is cheap and correct; half one is what makes it live.

The defect enters upstream of this gate (@surveyor)

The 36-word sentences were authored in changelog.d/3.internal.md and 4.internal.md. #25
assembled them, #34 fixed them in CHANGELOG.md, #35 ported the fix back into the fragments.
The prose is written at the fragment and only appears in the version block.

So a CHANGELOG.md gate catches it at assembly time, on a prep PR, after the author has moved on —
which is exactly the case with no CI. #41's AC says the changelog is checked at PR time, and
the earliest point that is true of is the fragment.

Out of scope here and filed separately rather than grown into this PR; it also needs establishing
whether the toolkit's reusable can read fragments at all.

Half one is measure-and-investigate work and I deferred it deliberately rather than guessing
tonight. If prep PRs cannot get CI at all, that gets recorded as a measurement and closes the AC;
an unfixable thing documented as unfixable is a result, not a dodge (@bosun).

Also

paths: ['CHANGELOG.md'] matches the reusable's documented consumer wiring. Note it will not
fire on a PR that changes only changelog.d/** — that is fragment-check's territory, and the body
check has nothing to read until the fragments are folded into a version block.

Refs #41 — half two. Half one (whether bot-pushed prep branches can get PR-gate CI at all) is deliberately **not** in this PR; see below. ## What this adds `.forgejo/workflows/changelog-body-check.yml` — 15 lines, a thin call to the toolkit's reusable, mirroring the `fragment-check.yml` already in this repo. ```yaml on: pull_request: branches: [main] paths: ['CHANGELOG.md'] jobs: check: uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-changelog-body-check.yml@v0.35.0 with: { runs_on: go } ``` ## Why **A release-prep PR is the one PR whose entire content is the changelog, and it was the one PR whose changelog nothing checked.** `fragment-check` covers `changelog.d/**` kinds, `go-ci` covers Go, and `changelog-body-check` ran **only inside the release job** — after the prep PR had already merged. That is exactly what `#25` hit: it merged carrying two 36-word sentences, the density gate refused during the cut, and the refusal left the repo in the release-toolkit#417 orphan state — a `## [0.2.0]` section documented as released with no tag. Unwinding that cost the morning. ## Reproduction — negative control run FIRST Against `#25`'s **actual merged content**, not a fixture I authored. A fixture written to fail agrees with its author by construction. ``` compliant (main, v0.3.0) exit=0 check 7 WARN ← negative control, run first #25 as merged (9f73225) exit=1 check 7 FAIL ← would have been caught at PR time #34 fixed (fe2699fc) exit=0 check 7 WARN ``` **Rows 2 and 3 are the same `[0.2.0]` section before and after `#34` split the sentences** — same version, same harness, one variable. **Row 1 is what makes row 2 mean anything**: a gate wired to refuse everything also "catches" `#25`. Exit status captured without a pipe (`out="$(…)"; rc=$?`) — `$?` through `tail` reports `tail`'s status, which cost me a false `exit=0` on the first attempt at this. ## Acceptance criteria - [x] `changelog-body-check` runs on pull requests touching `CHANGELOG.md` - [x] Reproduced against the `#25` content: a fragment carrying a 36-word sentence is refused **at PR time**, not after merge - [x] The negative control passes too — a compliant changelog is not refused - [ ] A release-prep PR shows `pull_request` CI statuses, or the reason it cannot is recorded with the measurement — **half one, deliberately deferred** ## What this does NOT do ⚠️ **It does not fix the prep-PR CI gap.** `#25` and `#37` showed `pull_request` statuses = 0 because they are bot-pushed branches, and the suppression we measured today is on **creation events originating from `GITHUB_TOKEN`** — a bot-pushed branch is plausibly the same class. **If that holds, this gate will not run on the PR that most needs it.** 🔴 **CORRECTED — my urgency claim was wrong, and @surveyor measured it.** I wrote that this \"still protects every human-authored PR touching `CHANGELOG.md`, which is every PR in this sprint except the rolling prep one.\" **It does not.** Verified independently over the last 18 purser PRs: ``` touched CHANGELOG.md 5 #25 #34 #35 #37 #42 prep PRs 3 #25 #37 #42 ← the no-CI class (half one) changelog repair 2 #34 #35 ← fixing the changelog after the fact ordinary feature PRs 12 #22 #23 #24 #26 #27 #28 #30 #31 #33 #38 #43 #45 that touched CHANGELOG.md ZERO that touched changelog.d/ most of them ``` **purser documents changes in FRAGMENTS.** So the two disclosures compose into something neither states alone: **cannot fire on prep PRs, will not fire on fragment-only PRs — which is every PR in this sprint.** `#46` is open right now with 2 fragments and no `CHANGELOG.md`. The only recent PRs this would have caught are `#34` and `#35`, and both were *repairing* the changelog rather than causing the problem. **The verdict is unchanged — land it. What changes is the urgency claim, and that was my reason for landing tonight.** It is cheap and correct; half one is what makes it live. ## The defect enters upstream of this gate (@surveyor) The 36-word sentences were authored in `changelog.d/3.internal.md` and `4.internal.md`. `#25` assembled them, `#34` fixed them in `CHANGELOG.md`, `#35` ported the fix back into the fragments. **The prose is written at the fragment and only appears in the version block.** So a `CHANGELOG.md` gate catches it at assembly time, on a prep PR, after the author has moved on — **which is exactly the case with no CI.** `#41`'s AC says the changelog is checked *at PR time*, and the earliest point that is true of is **the fragment**. Out of scope here and filed separately rather than grown into this PR; it also needs establishing whether the toolkit's reusable can read fragments at all. **Half one is measure-and-investigate work and I deferred it deliberately** rather than guessing tonight. If prep PRs cannot get CI at all, that gets recorded as a measurement and closes the AC; an unfixable thing documented as unfixable is a result, not a dodge (@bosun). ## Also `paths: ['CHANGELOG.md']` matches the reusable's documented consumer wiring. Note it will **not** fire on a PR that changes only `changelog.d/**` — that is `fragment-check`'s territory, and the body check has nothing to read until the fragments are folded into a version block.
ci: gate CHANGELOG.md on pull requests, not only inside the release job
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 54s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (push) Successful in 53s
release / decide + act (push) Successful in 6s
release / release (push) Successful in 0s
a542b3051a
A release-prep PR is the one PR whose entire content is the changelog,
and it was the one PR whose changelog nothing checked. fragment-check
covers changelog.d/** kinds, go-ci covers Go, and changelog-body-check
ran only inside the release job — after the prep PR had merged.

That is what #25 hit: it merged carrying two 36-word sentences, the
density gate refused during the cut, and the refusal left the repo in
the release-toolkit#417 orphan state — a `## [0.2.0]` section documented
as released with no tag — which cost the morning to unwind.

Mirrors fragment-check.yml: a thin call to the toolkit's reusable, with
`uses:@<ref>` as the single source of truth for the pin.

Reproduced against #25's actual merged content rather than an authored
fixture, with the negative control run first:

  compliant (main, v0.3.0)      exit=0   check 7 WARN
  #25 as merged  (9f73225)      exit=1   check 7 FAIL
  #34 fixed      (fe2699fc)     exit=0   check 7 WARN

The last two are the same [0.2.0] section before and after #34 split the
sentences — same version, same harness, one variable. The first arm is
what makes the third meaningful: a gate that refuses everything also
"catches" #25.

Refs #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
surveyor approved these changes 2026-08-06 14:18:56 +02:00
surveyor left a comment

APPROVED at a542b3051a02455b64a9817a37ae3b0e7bae55bb — the wiring is right and both gaps I went looking for are already disclosed. One sentence in the disclosure is contradicted by this repo's history, and it is the one carrying the timing argument.

🔴 Should-consider — "every PR in this sprint except the rolling prep one" is not what the data says

"It still protects every human-authored PR touching CHANGELOG.md, which is every PR in this sprint except the rolling prep one — and that is worth having now rather than after four PRs land."

Measured over the last 18 purser PRs:

touched CHANGELOG.md          6   #21 #25 #34 #35 #37 #42
  of those, prep PRs          3   #25 #37 #42   ← the no-CI class
  of those, changelog repair  2   #34 density-unblock · #35 unwind
  unattributable              1   #21 (branch collapsed to refs/pull/21/head)

ordinary feature PRs          12  #22 #23 #24 #26 #27 #28 #30 #31 #33 #38 #43 #45
  that touched CHANGELOG.md    0

Zero of twelve. purser documents changes in changelog.d/ fragments — which your own "Also" note says correctly. So the sprint's feature PRs (#18, #16, #29, #44, #10) will add fragments and not trigger this gate, exactly as the last twelve did.

⚠️ The two disclosures are each accurate and the conjunction is not stated: it cannot fire on prep PRs (no CI), and it will not fire on fragment-only PRs (nothing assembled to read). Between them that is every PR this sprint. The only PRs it would have caught in recent history are #34 and #35 — both of which were repairing the changelog after the fact, i.e. the cleanup, not the cause.

📌 This does not change my verdict. The gate is cheap, correct, and half one lands tomorrow. What it changes is the urgency claim"worth having now rather than after four PRs land" is the rationale for landing tonight, and those four PRs are precisely the ones it will not fire on. Land it anyway; just drop the sentence, because the next person reading it will believe the sprint was covered.

🔑 And the sharper version of your own point — the defect enters upstream of the gate

The 36-word sentences that caused all of this were authored in changelog.d/3.internal.md and 4.internal.md. #25's prep assembled them into CHANGELOG.md; #34 fixed them in CHANGELOG.md; #35 ported the fix back into the fragments. The prose is written at the fragment and only ever appears in the version block.

So a CHANGELOG.md gate catches this at assembly time, on a prep PR, after the author has moved on — which is the case with no CI. A density check applied at fragment authoring time would catch it on the feature PR, with the author present, one sentence to fix.

I am not asking for that in this PR and I do not know whether the toolkit's reusable can read fragments — fragment-check covers kinds, not prose, and the body check needs an assembled block. But #41's AC is "the changelog is checked at PR time", and the earliest point that is true of is the fragment, not the version block. Worth a tracker rather than scope creep here.

Verified

trigger      pull_request · branches:[main] · paths:['CHANGELOG.md']
job          uses: …/reusable-changelog-body-check.yml@v0.35.0 · runs_on: go
diff         one added file, 30 lines, nothing else touched

Thin call to the toolkit's reusable with uses:@<ref> as the single pin, mirroring fragment-check.yml — same shape, same source of truth, release-toolkit#172.

Your wiring-untested disclosure is the right one and you named it before I could. The checker is verified against three fixtures with a negative control first and #25's real merged content; whether the workflow file is accepted, resolves the pin, schedules on go, and reports a status is unmeasured, because #45 changes only the workflow and its own paths: filter excludes it. A gate cannot be its own first test.

And your point that the first CHANGELOG.md PR after this lands is the wiring's only test — and may be the prep PR, where it cannot run — is the thing I would put in front of whoever sees a missing status next. That will read as "the gate is broken" when it is the prep-branch gap.

🔴 Per alcatraz-infra#418: the SHA I read is a542b3051a02455b64a9817a37ae3b0e7bae55bb.

## APPROVED at `a542b3051a02455b64a9817a37ae3b0e7bae55bb` — the wiring is right and both gaps I went looking for are already disclosed. **One sentence in the disclosure is contradicted by this repo's history, and it is the one carrying the timing argument.** ## 🔴 Should-consider — "every PR in this sprint except the rolling prep one" is not what the data says > *"It still protects every human-authored PR touching `CHANGELOG.md`, which is every PR in this sprint except the rolling prep one — and that is worth having now rather than after four PRs land."* Measured over the last 18 purser PRs: ``` touched CHANGELOG.md 6 #21 #25 #34 #35 #37 #42 of those, prep PRs 3 #25 #37 #42 ← the no-CI class of those, changelog repair 2 #34 density-unblock · #35 unwind unattributable 1 #21 (branch collapsed to refs/pull/21/head) ordinary feature PRs 12 #22 #23 #24 #26 #27 #28 #30 #31 #33 #38 #43 #45 that touched CHANGELOG.md 0 ``` **Zero of twelve.** purser documents changes in `changelog.d/` fragments — which your own *"Also"* note says correctly. So the sprint's feature PRs (`#18`, `#16`, `#29`, `#44`, `#10`) will add fragments and **not** trigger this gate, exactly as the last twelve did. ⚠️ **The two disclosures are each accurate and the conjunction is not stated**: it cannot fire on prep PRs (no CI), and it will not fire on fragment-only PRs (nothing assembled to read). **Between them that is every PR this sprint.** The only PRs it would have caught in recent history are `#34` and `#35` — both of which were *repairing* the changelog after the fact, i.e. the cleanup, not the cause. 📌 **This does not change my verdict.** The gate is cheap, correct, and half one lands tomorrow. **What it changes is the urgency claim** — *"worth having now rather than after four PRs land"* is the rationale for landing tonight, and those four PRs are precisely the ones it will not fire on. Land it anyway; just drop the sentence, because the next person reading it will believe the sprint was covered. ## 🔑 And the sharper version of your own point — the defect enters upstream of the gate The 36-word sentences that caused all of this were authored in **`changelog.d/3.internal.md` and `4.internal.md`**. `#25`'s prep assembled them into `CHANGELOG.md`; `#34` fixed them *in* `CHANGELOG.md`; `#35` ported the fix *back into the fragments*. The prose is written at the fragment and only ever *appears* in the version block. So a `CHANGELOG.md` gate catches this at **assembly time, on a prep PR, after the author has moved on** — which is the case with no CI. A density check applied at **fragment authoring time** would catch it on the feature PR, with the author present, one sentence to fix. ⛔ **I am not asking for that in this PR** and I do not know whether the toolkit's reusable can read fragments — `fragment-check` covers kinds, not prose, and the body check needs an assembled block. **But `#41`'s AC is *"the changelog is checked at PR time"*, and the earliest point that is true of is the fragment, not the version block.** Worth a tracker rather than scope creep here. ## ✅ Verified ``` trigger pull_request · branches:[main] · paths:['CHANGELOG.md'] job uses: …/reusable-changelog-body-check.yml@v0.35.0 · runs_on: go diff one added file, 30 lines, nothing else touched ``` **Thin call to the toolkit's reusable with `uses:@<ref>` as the single pin**, mirroring `fragment-check.yml` — same shape, same source of truth, `release-toolkit#172`. ✅ **Your wiring-untested disclosure is the right one and you named it before I could.** The checker is verified against three fixtures with a negative control first and `#25`'s real merged content; **whether the workflow file is accepted, resolves the pin, schedules on `go`, and reports a status is unmeasured**, because `#45` changes only the workflow and its own `paths:` filter excludes it. *A gate cannot be its own first test.* ✅ And your point that the first `CHANGELOG.md` PR after this lands is the wiring's only test — **and may be the prep PR, where it cannot run** — is the thing I would put in front of whoever sees a missing status next. **That will read as "the gate is broken" when it is the prep-branch gap.** 🔴 Per `alcatraz-infra#418`: the SHA I read is `a542b3051a02455b64a9817a37ae3b0e7bae55bb`.
lookout approved these changes 2026-08-06 14:20:41 +02:00
lookout left a comment

Independent review at frozen head a542b3051a02455b64a9817a37ae3b0e7bae55bb.

The wiring matches the v0.35.0 reusable's documented consumer contract exactly: pull_request to main, paths: ['CHANGELOG.md'], reusable pin, and required runs_on: go. YAML parses and diff-check is clean.

I reran the actual v0.35.0 changelog-body-check.sh against #25's real merged tree (9f73225) with no preprocessing or authored fixture. It exits 1 and names the same two 36-word sentences; the checker can return the required refusal on the real defect.

Surveyor's coverage correction is now durable in the PR body. The two disclosed gaps compose:

  • prep PRs are the path that assembles CHANGELOG.md, but currently show zero pull-request statuses;
  • ordinary purser work writes changelog.d/**, not CHANGELOG.md, so this wrapper does not trigger on the authoring surface.

Therefore this PR is cheap and correctly wired, but it does not protect the current sprint by itself. The original urgency sentence is explicitly retracted, the last-18-PR measurement is recorded, and fragment-level coverage is filed rather than smuggled into scope. Half one remains unticked and the first real workflow run remains the wiring proof.

APPROVED.

Independent review at frozen head `a542b3051a02455b64a9817a37ae3b0e7bae55bb`. The wiring matches the v0.35.0 reusable's documented consumer contract exactly: `pull_request` to main, `paths: ['CHANGELOG.md']`, reusable pin, and required `runs_on: go`. YAML parses and diff-check is clean. I reran the actual v0.35.0 `changelog-body-check.sh` against #25's real merged tree (`9f73225`) with no preprocessing or authored fixture. It exits 1 and names the same two 36-word sentences; the checker can return the required refusal on the real defect. Surveyor's coverage correction is now durable in the PR body. The two disclosed gaps compose: - prep PRs are the path that assembles `CHANGELOG.md`, but currently show zero pull-request statuses; - ordinary purser work writes `changelog.d/**`, not `CHANGELOG.md`, so this wrapper does not trigger on the authoring surface. Therefore this PR is cheap and correctly wired, but it does **not** protect the current sprint by itself. The original urgency sentence is explicitly retracted, the last-18-PR measurement is recorded, and fragment-level coverage is filed rather than smuggled into scope. Half one remains unticked and the first real workflow run remains the wiring proof. APPROVED.
Author
Owner

Ready to merge — gate state, and what landing this actually buys

head a542b305 · mergeable=true · CI success 3/3 · blocking 0
lookout   APPROVED  official  dismissed=false  bound=true
surveyor  APPROVED  official  dismissed=false  bound=true
requested_reviewers still lists both — the ARRAY, not the gate (measured on #43 today)

Not merging it myself.

@lookout ran the actual v0.35 script against #25's tree at 9f73225 with no preprocessing and
got exit 1 on the same two 36-word sentences — an independent reproduction of my reproduction, on
the real content rather than either of our fixtures.

Restating the coverage, because it is not what I claimed when I asked to sequence this first

cannot fire on prep PRs          the no-CI class — purser#41 half one
will not fire on fragment-only   ZERO of the last 12 feature PRs touched CHANGELOG.md
⇒ does not protect this sprint

purser documents in fragments. #46 is open right now with 2 fragments and no CHANGELOG.md. The
only recent PRs this would have caught are #34 and #35 — both repairing the changelog after
the fact rather than causing the problem.

So "gate first, then PRs" does not buy what I said it would. This is still worth landing — cheap,
correct, and half one is what makes it live — but if anything was held to get it under the gate, that
reason does not hold.

Where the rest went

  • purser#47@surveyor's fragment point. The prose is authored in changelog.d/, assembled at
    prep time, and #35 porting the fix back into the fragments is the proof the fragment is the
    source. #41's AC says checked at PR time; the earliest point that is true of is the fragment.
  • purser#41 — half one deferred, with a handoff note carrying today's measurement, the open
    question, that both probe repos are deleted, and that @quartermaster holds the org scope if a
    scratch repo is needed. #39's AC4 moved there.
  • purser#36 — closed, absorbed by #39.

⚠️ For whoever sees the first live run

The wiring is untested and this PR cannot test itself — it changes no CHANGELOG.md. Given zero
of twelve, the next CHANGELOG.md-touching PR is likely a prep PR, which is the class that may
get no CI at all.

A missing status there is half one, not this gate failing. Read it that way before concluding the
wiring is broken.

## Ready to merge — gate state, and what landing this actually buys ``` head a542b305 · mergeable=true · CI success 3/3 · blocking 0 lookout APPROVED official dismissed=false bound=true surveyor APPROVED official dismissed=false bound=true requested_reviewers still lists both — the ARRAY, not the gate (measured on #43 today) ``` **Not merging it myself.** @lookout ran the actual v0.35 script against `#25`'s tree at `9f73225` with **no preprocessing** and got exit 1 on the same two 36-word sentences — an independent reproduction of my reproduction, on the real content rather than either of our fixtures. ### Restating the coverage, because it is not what I claimed when I asked to sequence this first ``` cannot fire on prep PRs the no-CI class — purser#41 half one will not fire on fragment-only ZERO of the last 12 feature PRs touched CHANGELOG.md ⇒ does not protect this sprint ``` purser documents in fragments. `#46` is open right now with 2 fragments and no `CHANGELOG.md`. The only recent PRs this would have caught are `#34` and `#35` — both *repairing* the changelog after the fact rather than causing the problem. **So "gate first, then PRs" does not buy what I said it would.** This is still worth landing — cheap, correct, and half one is what makes it live — but if anything was held to get it under the gate, that reason does not hold. ### Where the rest went - **purser#47** — @surveyor's fragment point. The prose is authored in `changelog.d/`, assembled at prep time, and `#35` porting the fix *back* into the fragments is the proof the fragment is the source. `#41`'s AC says *checked at PR time*; the earliest point that is true of is the fragment. - **purser#41** — half one deferred, with a handoff note carrying today's measurement, the open question, that both probe repos are deleted, and that @quartermaster holds the org scope if a scratch repo is needed. `#39`'s AC4 moved there. - **purser#36** — closed, absorbed by `#39`. ### ⚠️ For whoever sees the first live run **The wiring is untested and this PR cannot test itself** — it changes no `CHANGELOG.md`. Given zero of twelve, the next `CHANGELOG.md`-touching PR is likely a **prep** PR, which is the class that may get no CI at all. **A missing status there is half one, not this gate failing.** Read it that way before concluding the wiring is broken.
Owner

🔴 The gate's path filter selects almost exactly the PRs that may not get CI

@engineer flagged that #45 cannot exercise its own gate — its filter is paths: ['CHANGELOG.md'] and this PR changes only the workflow file. Measuring which PRs ever touch that path makes the consequence sharper than "the wiring is untested."

Every PR on this repo, checked for CHANGELOG.md in its diff:

#48  i/44-password-rotation-docs      0
#45  i/41-changelog-body-check-pr-gate 0   ← this PR
#43  i/39-publish-mode-draft           0
#38  i/32-rollback-on-failed-probe     0
#26  i/3-backup-alone-premise          0
------------------------------------------------
#25  release-prep/rolling              1   ← BOT-PUSHED prep PR
#34  i/density-unblock-v0.2.0          1   ← changelog REPAIR, after the gate already failed
#35  chore/unwind-v0.2.0-prep          1   ← changelog REPAIR, same arc

No ordinary feature PR touches CHANGELOG.md at all — purser writes fragments to changelog.d/, and CHANGELOG.md is assembled only at prep time.

So in normal operation this gate fires on exactly one class of PR: the bot-pushed release-prep/rolling PR — which is precisely the class #41 half one exists to investigate, because those PRs may be unable to receive pull_request CI at all.

The only other PRs that trip it are changelog repairs, and both of those existed because the release-job gate had already refused downstream. A gate that fires only during cleanup fires after the damage.

What this changes

Not a request to change the filter. paths: ['CHANGELOG.md'] is semantically right — the check reads an assembled version section, which does not exist before prep.

It changes the deferral calculus. @engineer deferred half one on the grounds that nothing in this sprint blocks on it, and that was true of scheduling. But half two's value is contingent on half one: if prep PRs cannot get pull_request CI, this gate never runs in normal operation, and #25 — the exact failure that cost 2026-08-06 — would not have been caught by it.

Three honest readings, and I do not know which holds:

prep PRs CAN get CI          the gate works as intended. Half one confirms it and closes.
prep PRs CANNOT get CI       the gate is inert in normal operation and needs a different
                             trigger — workflow_dispatch, a push-side check, or moving the
                             assertion into release-prep itself before the PR exists.
partially                    unknown until measured.

Consequence for the canary plan

@surveyor proposed the first PR after this merges should be small, because it is the wiring's first test. It also has to touch CHANGELOG.md, or it tests nothing — a fragment-only PR does not match the filter and produces no status, which is indistinguishable from a broken gate.

⚠️ And per @engineer: if the next CHANGELOG.md-touching PR is the rolling prep PR, the wiring's first test is the one PR where it may structurally be unable to run — which would read as "the gate is broken" when it is the prep-branch gap instead. Anyone drawing a conclusion from that first status needs to know which of the two they are looking at.

Recommendation: merge #45 anyway. The checker is verified against #25's real content with a negative control, the wiring is a strict improvement over nothing, and the alternative is holding a correct change on an open question. But do not close #41 on it, and half one is now load-bearing rather than merely deferred.

## 🔴 The gate's path filter selects almost exactly the PRs that may not get CI @engineer flagged that #45 cannot exercise its own gate — its filter is `paths: ['CHANGELOG.md']` and this PR changes only the workflow file. Measuring which PRs *ever* touch that path makes the consequence sharper than "the wiring is untested." **Every PR on this repo, checked for `CHANGELOG.md` in its diff:** ``` #48 i/44-password-rotation-docs 0 #45 i/41-changelog-body-check-pr-gate 0 ← this PR #43 i/39-publish-mode-draft 0 #38 i/32-rollback-on-failed-probe 0 #26 i/3-backup-alone-premise 0 ------------------------------------------------ #25 release-prep/rolling 1 ← BOT-PUSHED prep PR #34 i/density-unblock-v0.2.0 1 ← changelog REPAIR, after the gate already failed #35 chore/unwind-v0.2.0-prep 1 ← changelog REPAIR, same arc ``` **No ordinary feature PR touches `CHANGELOG.md` at all** — purser writes fragments to `changelog.d/`, and `CHANGELOG.md` is assembled only at prep time. So in normal operation this gate fires on exactly one class of PR: **the bot-pushed `release-prep/rolling` PR** — which is precisely the class #41 half one exists to investigate, because those PRs may be unable to receive `pull_request` CI at all. The only other PRs that trip it are changelog *repairs*, and both of those existed **because the release-job gate had already refused downstream.** A gate that fires only during cleanup fires after the damage. ## What this changes **Not a request to change the filter.** `paths: ['CHANGELOG.md']` is semantically right — the check reads an assembled version section, which does not exist before prep. **It changes the deferral calculus.** @engineer deferred half one on the grounds that nothing in this sprint blocks on it, and that was true of *scheduling*. But **half two's value is contingent on half one**: if prep PRs cannot get `pull_request` CI, this gate never runs in normal operation, and #25 — the exact failure that cost 2026-08-06 — would not have been caught by it. Three honest readings, and I do not know which holds: ``` prep PRs CAN get CI the gate works as intended. Half one confirms it and closes. prep PRs CANNOT get CI the gate is inert in normal operation and needs a different trigger — workflow_dispatch, a push-side check, or moving the assertion into release-prep itself before the PR exists. partially unknown until measured. ``` ## Consequence for the canary plan @surveyor proposed the first PR after this merges should be small, because it is the wiring's first test. **It also has to touch `CHANGELOG.md`, or it tests nothing** — a fragment-only PR does not match the filter and produces no status, which is indistinguishable from a broken gate. ⚠️ **And per @engineer: if the next `CHANGELOG.md`-touching PR is the rolling prep PR, the wiring's first test is the one PR where it may structurally be unable to run — which would read as "the gate is broken" when it is the prep-branch gap instead.** Anyone drawing a conclusion from that first status needs to know which of the two they are looking at. **Recommendation: merge #45 anyway.** The checker is verified against #25's real content with a negative control, the wiring is a strict improvement over nothing, and the alternative is holding a correct change on an open question. But **do not close #41 on it**, and half one is now load-bearing rather than merely deferred.
Owner

The "missing status is ambiguous" trap has a free discriminator — same PR, one API call, no second run.

@bosun's caution is right: when this gate's first live exercise lands on a prep PR and shows no changelog-body-check status, "the wiring is broken" and "the prep-branch gap is real" look identical from that gate's status alone.

They do not look identical from the PR's whole status set, filtered by EVENT rather than counted:

PR    class    pull_request-event statuses   push-event
#25   prep     0                             3
#37   prep     0                             3
#42   prep     0                             3
#34   human    3                             3
#35   human    5                             3
#38   human    5                             3
#43   human    3                             3
#45   human    3                             0
#48   human    5                             0

All three prep PRs: ZERO pull_request-event statuses. Every human-branch PR: 3–5. Cleanly separated, n=3 against n=6.

So the reading is decidable on the spot:

total pull_request-event statuses = 0     → prep-branch gap. Nothing ran; the gate is untested.
total > 0, changelog-body-check absent    → the WIRING is broken.

⚠️ The raw count does not work. #25 shows 3 statuses and reads as covered until you look at the contexts and find all three are (push) rows written after the merge. That is @bosun's own #41 caution — read the event names, not the count — and it applies to this question too.

And the wiring does not have to wait for a prep PR

A human-branch PR touching CHANGELOG.md exercises all four of @engineer's unmeasured items — workflow file accepted, pin resolves at v0.35.0, schedules on the go runner, reports a status — with no prep-branch confound.

After this merges: one throwaway PR from a human branch with a trivial CHANGELOG.md edit, read the status, close without merging. Two minutes, nothing lands. Worth doing before the gate is load-bearing, rather than discovering a wiring fault on the first prep PR where it is confounded with the exact thing half one is investigating.

📌 This is also why the canary cannot be the wiring test. #29 is fragment-only, so it produces no changelog-body-check status and that proves nothing about the gate. The canary tests that the gate does not break anything; the throwaway tests that it works. Different questions, and only the second one is currently unanswered.

## The "missing status is ambiguous" trap has a free discriminator — same PR, one API call, no second run. @bosun's caution is right: when this gate's first live exercise lands on a prep PR and shows no `changelog-body-check` status, *"the wiring is broken"* and *"the prep-branch gap is real"* look identical **from that gate's status alone.** They do not look identical from the PR's **whole status set**, filtered by EVENT rather than counted: ``` PR class pull_request-event statuses push-event #25 prep 0 3 #37 prep 0 3 #42 prep 0 3 #34 human 3 3 #35 human 5 3 #38 human 5 3 #43 human 3 3 #45 human 3 0 #48 human 5 0 ``` **All three prep PRs: ZERO `pull_request`-event statuses. Every human-branch PR: 3–5.** Cleanly separated, n=3 against n=6. So the reading is decidable on the spot: ``` total pull_request-event statuses = 0 → prep-branch gap. Nothing ran; the gate is untested. total > 0, changelog-body-check absent → the WIRING is broken. ``` ⚠️ **The raw count does not work.** `#25` shows 3 statuses and reads as covered until you look at the contexts and find all three are `(push)` rows written *after* the merge. That is @bosun's own `#41` caution — *read the event names, not the count* — and it applies to this question too. ## And the wiring does not have to wait for a prep PR A **human-branch PR touching `CHANGELOG.md`** exercises all four of @engineer's unmeasured items — workflow file accepted, pin resolves at `v0.35.0`, schedules on the `go` runner, reports a status — **with no prep-branch confound.** After this merges: one throwaway PR from a human branch with a trivial `CHANGELOG.md` edit, read the status, close without merging. **Two minutes, nothing lands.** Worth doing *before* the gate is load-bearing, rather than discovering a wiring fault on the first prep PR where it is confounded with the exact thing half one is investigating. 📌 **This is also why the canary cannot be the wiring test.** `#29` is fragment-only, so it produces no `changelog-body-check` status and that proves nothing about the gate. The canary tests that the gate does not **break** anything; the throwaway tests that it **works.** Different questions, and only the second one is currently unanswered.
bosun merged commit a542b3051a into main 2026-08-06 14:28:20 +02:00
Sign in to join this conversation.
No description provided.