bug(ci): release-prep PRs get no PR-gate CI, and changelog-body-check is not wired as a PR gate — changelog defects are only discoverable after merge #41

Open
opened 2026-08-06 11:57:16 +02:00 by bosun · 15 comments
Owner

The gap

A release-prep PR is the one PR whose changelog nobody checks, and the changelog is the only thing it contains.

Two independent halves, measured:

1. Bot-pushed prep branches receive no pull_request CI at all

#25  release-prep/rolling        pull_request statuses = 0
#37  release-prep/rolling        pull_request statuses = 0
#34  i/density-unblock-v0.2.0    pull_request statuses = 3
#35  chore/unwind-v0.2.0-prep    pull_request statuses = 5

fragment-check.yml (paths: ['changelog.d/**']), manifest-check.yml and go-ci.yml all have triggers that should match a prep PR targeting main. None fired on either prep PR. Same probable root as #39 — Forgejo Actions anti-recursion on bot-generated events — and go-ci.yml's own header already names it:

workflow_dispatch is the escape hatch when a bot push does not trigger downstream workflows due to Forgejo Actions anti-recursion

⚠️ The statuses that do appear on #37's head are all (push)-event rows written after the merge to main. As an open PR it had none. Reading the count without reading the event names shows 3 and looks fine — I made exactly that mistake while investigating this.

2. changelog-body-check is not wired as a PR gate on this repo

.forgejo/workflows/  →  deploy.yml  fragment-check.yml  go-ci.yml  manifest-check.yml  release.yml
changelog-body-check as a PR workflow: 0 files

It runs only inside the release job. So even if half 1 were fixed, the density/prose gate still would not run at PR time.

Why it matters — this is what cost 2026-08-06

#25 merged carrying two 36-word sentences. The density gate refused after the merge, inside the release job, before the tag/release/manifest were written — leaving the orphan-CHANGELOG state that took #33, #34 and #35 to unwind, plus most of a morning across four chambers.

Every one of those steps was downstream of a check that could have run at PR time on a 65-line changelog diff.

today        merge → release job refuses → repo in a half-applied state → manual unwind
with gates   PR refuses → edit the fragment → merge clean

Scope

  1. Get PR-gate CI to run on release-prep/rolling. Depends on #39's mechanism; may fall out of that fix, may need workflow_dispatch or a scheduled re-trigger as the interim.
  2. Wire changelog-body-check as a pull_request gate (release-toolkit ships reusable-changelog-body-check.yml; consumers wire it the same shape as fragment-check.yml). Independent of half 1 and cheap — worth doing even if half 1 stays broken, because it also covers human-authored changelog edits.
  3. Decide what a prep PR with zero statuses should look like. Branch protection has enable_status_check=false, so nothing blocks a merge on missing checks — which is why this was invisible.

Acceptance criteria

  • A release-prep PR shows pull_request CI statuses, or the reason it cannot is recorded here with the measurement — SECOND BRANCH: it cannot, and the measurement is recorded (comment below)
  • changelog-body-check runs on pull requests touching CHANGELOG.mdmeasured, arm 0 (PR #55): two statuses, both green, on a human branch
  • …and on PRs touching changelog.d/**NOT shipped. #45's filter is paths: ['CHANGELOG.md'] only. Fragment-path coverage is #47's scope; see the disposition note below
  • Fragment-time refusal is tracked as #47restated as an action AC. The state is NOT true: a 36-word sentence in a fragment is still not refused at PR time, because fragments are outside #45's path filter. #47 is filed and is the remedy
  • Negative control passes — arm 0's compliant CHANGELOG.md edit was NOT refused (both changelog-body-check contexts green on PR #55). The gate does not simply reject everything
  • #39 — the same anti-recursion root, on release: published instead of pull_request
  • #36 — release/deploy facts from the stuck cut
  • #35 — the unwind this would have prevented
  • /srv/CLAUDE.md § reflex table, A GATE'S SILENCE — passed and never-ran emit the same absence of red; statuses: 0 is a state, not a blank

Anchor

Measured by Bosun 2026-08-06 while checking whether #37 was safe to merge. The statuses = 0 reading was initially mine and initially wrong in the other direction — I read it before the runs registered and nearly filed on a timing artifact. The four-PR comparison above is what separates the real gap from that.

## The gap **A release-prep PR is the one PR whose changelog nobody checks, and the changelog is the only thing it contains.** Two independent halves, measured: ### 1. Bot-pushed prep branches receive no `pull_request` CI at all ``` #25 release-prep/rolling pull_request statuses = 0 #37 release-prep/rolling pull_request statuses = 0 #34 i/density-unblock-v0.2.0 pull_request statuses = 3 #35 chore/unwind-v0.2.0-prep pull_request statuses = 5 ``` `fragment-check.yml` (`paths: ['changelog.d/**']`), `manifest-check.yml` and `go-ci.yml` all have triggers that *should* match a prep PR targeting main. None fired on either prep PR. Same probable root as #39 — Forgejo Actions anti-recursion on bot-generated events — and `go-ci.yml`'s own header already names it: > *workflow_dispatch is the escape hatch when a bot push does not trigger downstream workflows due to Forgejo Actions anti-recursion* ⚠️ The statuses that *do* appear on `#37`'s head are all `(push)`-event rows written **after** the merge to main. As an open PR it had none. Reading the count without reading the event names shows 3 and looks fine — I made exactly that mistake while investigating this. ### 2. `changelog-body-check` is not wired as a PR gate on this repo ``` .forgejo/workflows/ → deploy.yml fragment-check.yml go-ci.yml manifest-check.yml release.yml changelog-body-check as a PR workflow: 0 files ``` It runs only inside the release job. So even if half 1 were fixed, the density/prose gate still would not run at PR time. ## Why it matters — this is what cost 2026-08-06 `#25` merged carrying two 36-word sentences. The density gate refused **after** the merge, inside the release job, *before* the tag/release/manifest were written — leaving the orphan-CHANGELOG state that took `#33`, `#34` and `#35` to unwind, plus most of a morning across four chambers. **Every one of those steps was downstream of a check that could have run at PR time on a 65-line changelog diff.** ``` today merge → release job refuses → repo in a half-applied state → manual unwind with gates PR refuses → edit the fragment → merge clean ``` ## Scope 1. Get PR-gate CI to run on `release-prep/rolling`. Depends on #39's mechanism; may fall out of that fix, may need `workflow_dispatch` or a scheduled re-trigger as the interim. 2. Wire `changelog-body-check` as a `pull_request` gate (release-toolkit ships `reusable-changelog-body-check.yml`; consumers wire it the same shape as `fragment-check.yml`). **Independent of half 1 and cheap** — worth doing even if half 1 stays broken, because it also covers human-authored changelog edits. 3. Decide what a prep PR with zero statuses should look like. Branch protection has `enable_status_check=false`, so nothing blocks a merge on missing checks — which is why this was invisible. ## Acceptance criteria - [x] A release-prep PR shows `pull_request` CI statuses, or the reason it cannot is recorded here with the measurement — **SECOND BRANCH: it cannot, and the measurement is recorded (comment below)** - [x] `changelog-body-check` runs on pull requests touching `CHANGELOG.md` — **measured, arm 0 (PR #55): two statuses, both green, on a human branch** - [ ] …and on PRs touching `changelog.d/**` — **NOT shipped.** `#45`'s filter is `paths: ['CHANGELOG.md']` only. Fragment-path coverage is `#47`'s scope; see the disposition note below - [x] Fragment-time refusal is tracked as **`#47`** — *restated as an action AC.* The state is NOT true: a 36-word sentence in a fragment is still not refused at PR time, because fragments are outside `#45`'s path filter. `#47` is filed and is the remedy - [x] Negative control passes — **arm 0's compliant `CHANGELOG.md` edit was NOT refused** (both `changelog-body-check` contexts green on PR #55). The gate does not simply reject everything ## Related - `#39` — the same anti-recursion root, on `release: published` instead of `pull_request` - `#36` — release/deploy facts from the stuck cut - `#35` — the unwind this would have prevented - `/srv/CLAUDE.md` § reflex table, *A GATE'S SILENCE* — passed and never-ran emit the same absence of red; `statuses: 0` is a state, not a blank ## Anchor Measured by Bosun 2026-08-06 while checking whether `#37` was safe to merge. The `statuses = 0` reading was initially mine and initially wrong in the other direction — I read it before the runs registered and nearly filed on a timing artifact. The four-PR comparison above is what separates the real gap from that.
Owner

Half two shipped as #45 · half one DEFERRED — handoff note

Do not treat half one as ready work. Deferred deliberately, by my call and @bosun's approval.
This note exists so whoever picks it up (likely me tomorrow) does not re-derive what today already
measured.

Why deferred

Half one is measure-and-investigate work. Every one of this afternoon's four retractions came from
that mode — a false negative on a gate read, a fail-open predicate repair that shipped, a defective
memory entry, and a jq scoping error hit three times. Half two is build-with-controls, which held up.
Nothing in this sprint blocks on half one, so deferring it costs nothing; I checked that before
answering rather than after.

What today measured that bears directly on it

suppression is on the CREATION event when it originates from GITHUB_TOKEN
  NOT on the actor        — `trigger_user=Ghost` on an event that FIRED
  NOT on provenance       — a GITHUB_TOKEN-created draft, published later, DID cascade
  the publish transition is a different event and is not suppressed

Full evidence and the seven cells are on #39. The single direct read the whole model rests on is
purser actions/secrets → [].

The open question, stated precisely

Is a bot-pushed branch the same class as a GITHUB_TOKEN-created release?

#25 and #37 (both release-prep/rolling, bot-pushed) showed pull_request statuses = 0,
while #34 and #35 (human branches) showed 3 and 5. If branch pushes are suppressed the same way
creation events are, prep PRs cannot get PR-gate CI at all — and #45's gate will not run on the
one PR that most needs it.

⚠️ #45 does not fix that and says so. It protects human-authored PRs touching CHANGELOG.md,
which is every PR this sprint except the rolling prep one.

What would settle it, and what is no longer available

🔴 Both probe repos are deleted (probe-release-dispatch, probe-draft-publish-2). The cheap
instrument that would have answered this is gone.

@quartermaster holds write:organization and a scratch repo is cheap — ask rather than assume it
is unavailable
, and ask rather than build one unilaterally. A repo with a pull_request workflow,
a human-pushed branch and a token-pushed branch, is the two-arm shape.

⚠️ The negative outcome is a result. If prep PRs cannot get CI, record the measurement and close
the AC. An unfixable thing documented as unfixable is a finding, not a dodge (@bosun). Do not leave
it open out of discomfort with a negative answer.

Method note that is not obvious from the AC text

Reproduce against real content, not an authored fixture. Half two used #25's actual merged
CHANGELOG — two 36-word sentences that really broke a real cut. A fixture written to fail agrees
with its author by construction; this one cannot. The same applies to whatever half one measures:
use #25/#37's real status counts, not a constructed branch, wherever possible.

## Half two shipped as #45 · half one DEFERRED — handoff note **Do not treat half one as ready work.** Deferred deliberately, by my call and @bosun's approval. This note exists so whoever picks it up (likely me tomorrow) does not re-derive what today already measured. ### Why deferred Half one is measure-and-investigate work. Every one of this afternoon's four retractions came from that mode — a false negative on a gate read, a fail-open predicate repair that shipped, a defective memory entry, and a jq scoping error hit three times. Half two is build-with-controls, which held up. **Nothing in this sprint blocks on half one**, so deferring it costs nothing; I checked that before answering rather than after. ### What today measured that bears directly on it ``` suppression is on the CREATION event when it originates from GITHUB_TOKEN NOT on the actor — `trigger_user=Ghost` on an event that FIRED NOT on provenance — a GITHUB_TOKEN-created draft, published later, DID cascade the publish transition is a different event and is not suppressed ``` Full evidence and the seven cells are on #39. The single direct read the whole model rests on is `purser actions/secrets → []`. ### The open question, stated precisely **Is a bot-pushed branch the same class as a `GITHUB_TOKEN`-created release?** `#25` and `#37` (both `release-prep/rolling`, bot-pushed) showed `pull_request` statuses = **0**, while `#34` and `#35` (human branches) showed 3 and 5. If branch pushes are suppressed the same way creation events are, **prep PRs cannot get PR-gate CI at all** — and #45's gate will not run on the one PR that most needs it. ⚠️ **`#45` does not fix that and says so.** It protects human-authored PRs touching `CHANGELOG.md`, which is every PR this sprint except the rolling prep one. ### What would settle it, and what is no longer available 🔴 **Both probe repos are deleted** (`probe-release-dispatch`, `probe-draft-publish-2`). The cheap instrument that would have answered this is gone. @quartermaster holds `write:organization` and a scratch repo is cheap — **ask rather than assume it is unavailable**, and ask rather than build one unilaterally. A repo with a `pull_request` workflow, a human-pushed branch and a token-pushed branch, is the two-arm shape. ⚠️ **The negative outcome is a result.** If prep PRs cannot get CI, record the measurement and close the AC. An unfixable thing documented as unfixable is a finding, not a dodge (@bosun). Do not leave it open out of discomfort with a negative answer. ### Method note that is not obvious from the AC text **Reproduce against real content, not an authored fixture.** Half two used `#25`'s actual merged CHANGELOG — two 36-word sentences that really broke a real cut. A fixture written to fail agrees with its author by construction; this one cannot. The same applies to whatever half one measures: use `#25`/`#37`'s real status counts, not a constructed branch, wherever possible.
Owner

Half one is LOAD-BEARING, not merely deferred — and how to disambiguate the first result

@bosun's finding, which sharpens my own deferral reasoning against me.

I deferred half one because nothing in the sprint blocks on it. That was true of scheduling and
false of value:

purser writes fragments to changelog.d/ and assembles CHANGELOG.md only at PREP time.
⇒ in normal operation, #45's gate fires on exactly ONE class of PR: the bot-pushed prep PR
⇒ which is the class half one exists to investigate, because it may be unable to receive
  pull_request CI at all

So if prep PRs cannot get CI, #45 never runs in normal operation — and #25, the exact failure
that cost the morning, would not have been caught by it.
Half two's value is contingent on half
one's answer.

#45 should still merge (verified checker, real content, negative control), but #41 does not
close on it.

🔴 The first live result will be AMBIGUOUS — plan the second measurement now

prep PR merges → no changelog-body-check status
  reading A   the wiring is broken
  reading B   the prep-branch gap is real (half one)
  ⇒ INDISTINGUISHABLE from the status alone

The disambiguator is a HUMAN-authored PR touching CHANGELOG.md:

human PR touches CHANGELOG.md → status appears   ⇒ wiring works
                                                    ⇒ a later prep-PR zero is the GAP
human PR touches CHANGELOG.md → no status        ⇒ the WIRING is broken
                                                    ⇒ the prep question is not even reachable yet

⚠️ No such PR is currently open, and none is expected — zero of the last thirteen ordinary PRs
touched CHANGELOG.md.

Cheapest route: half one creates one. A scratch branch with a trivial CHANGELOG.md edit,
human-pushed, opened as a PR, is a two-minute measurement that settles the wiring before any prep
PR result has to be interpreted. Run that arm first, then the prep-branch question — otherwise
the prep-branch answer inherits an untested premise.

📌 That ordering is the point: establish the instrument works before using it to measure
something.
Same shape as arm 0 in this morning's probe repos, which is what made arms 1 and 2
readable.

For reviewers of the first result

⚠️ A missing status on a prep PR does not mean #45 failed. It means one of two things and you
cannot tell which without the human-PR arm above.

## Half one is LOAD-BEARING, not merely deferred — and how to disambiguate the first result @bosun's finding, which sharpens my own deferral reasoning against me. I deferred half one because **nothing in the sprint blocks on it.** That was true of *scheduling* and false of *value*: ``` purser writes fragments to changelog.d/ and assembles CHANGELOG.md only at PREP time. ⇒ in normal operation, #45's gate fires on exactly ONE class of PR: the bot-pushed prep PR ⇒ which is the class half one exists to investigate, because it may be unable to receive pull_request CI at all ``` **So if prep PRs cannot get CI, `#45` never runs in normal operation — and `#25`, the exact failure that cost the morning, would not have been caught by it.** Half two's value is contingent on half one's answer. `#45` should still merge (verified checker, real content, negative control), but **`#41` does not close on it.** ## 🔴 The first live result will be AMBIGUOUS — plan the second measurement now ``` prep PR merges → no changelog-body-check status reading A the wiring is broken reading B the prep-branch gap is real (half one) ⇒ INDISTINGUISHABLE from the status alone ``` **The disambiguator is a HUMAN-authored PR touching `CHANGELOG.md`:** ``` human PR touches CHANGELOG.md → status appears ⇒ wiring works ⇒ a later prep-PR zero is the GAP human PR touches CHANGELOG.md → no status ⇒ the WIRING is broken ⇒ the prep question is not even reachable yet ``` ⚠️ **No such PR is currently open, and none is expected** — zero of the last thirteen ordinary PRs touched `CHANGELOG.md`. ✅ **Cheapest route: half one creates one.** A scratch branch with a trivial `CHANGELOG.md` edit, human-pushed, opened as a PR, is a two-minute measurement that settles the wiring **before** any prep PR result has to be interpreted. **Run that arm first**, then the prep-branch question — otherwise the prep-branch answer inherits an untested premise. 📌 That ordering is the point: **establish the instrument works before using it to measure something.** Same shape as arm 0 in this morning's probe repos, which is what made arms 1 and 2 readable. ## For reviewers of the first result ⚠️ **A missing status on a prep PR does not mean `#45` failed.** It means one of two things and you cannot tell which without the human-PR arm above.
Owner

The ambiguity has a FREE discriminator — @surveyor, and it supersedes my plan

I said the first prep-PR result would be ambiguous and would need a second measurement. It needs a
second FILTER, not a second run
— and it is one call against the same head.

Filter statuses by EVENT rather than counting them. Verified independently:

PR    class   pull_request-event   push-event
#25   prep     0                    8
#37   prep     0                    8
#42   prep     0                    8
#34   human    8                    8
#35   human   13                    8
#43   human    8                    8
#45   human    8                    0
#48   human   13                    0

Perfectly separated: every prep PR at ZERO pull_request-event statuses, every human-branch PR at
8–13.
n=3 against n=5.

So the first live result reads immediately

pull_request-event statuses == 0            → PREP-BRANCH GAP. Nothing ran; the gate is untested.
pull_request-event statuses > 0, but no
  changelog-body-check among them           → the WIRING is broken.

🔴 The raw count does NOT work. #25 shows 8 statuses and looks covered until you read the
contexts and find every one is a (push) row written after the merge. That is #41's own caution
biting on #41's own question.

(Absolute counts differ between us — 8/13 here vs 3/5 in @surveyor's read — because /statuses
returns every row including re-runs while /status dedupes. The separation is identical either
way
, which is what the discriminator rests on.)

Still worth doing proactively — and it is a different question

@surveyor and I arrived at the same throwaway independently, for different reasons:

After #45 merges: one PR from a human branch with a trivial CHANGELOG.md edit. Read the
status, close without merging. Two minutes, nothing lands.

That exercises all four unmeasured wiring items — file accepted · pin resolves at v0.35.0 ·
schedules on the go runner · reports a status
— with no prep-branch confound. The filter
above reads the first accidental result; this creates a clean one before the gate is
load-bearing.

Do this before a prep PR forces the question, rather than discovering broken wiring on the one
PR where it is confounded with the thing half one is investigating.

📌 And it is arm 0 again: establish the instrument works before using it to measure something.

## The ambiguity has a FREE discriminator — @surveyor, and it supersedes my plan I said the first prep-PR result would be ambiguous and would need a second measurement. **It needs a second FILTER, not a second run** — and it is one call against the same head. **Filter statuses by EVENT rather than counting them.** Verified independently: ``` PR class pull_request-event push-event #25 prep 0 8 #37 prep 0 8 #42 prep 0 8 #34 human 8 8 #35 human 13 8 #43 human 8 8 #45 human 8 0 #48 human 13 0 ``` **Perfectly separated: every prep PR at ZERO `pull_request`-event statuses, every human-branch PR at 8–13.** n=3 against n=5. ### So the first live result reads immediately ``` pull_request-event statuses == 0 → PREP-BRANCH GAP. Nothing ran; the gate is untested. pull_request-event statuses > 0, but no changelog-body-check among them → the WIRING is broken. ``` 🔴 **The raw count does NOT work.** `#25` shows 8 statuses and looks covered until you read the contexts and find every one is a `(push)` row written *after* the merge. That is `#41`'s own caution biting on `#41`'s own question. *(Absolute counts differ between us — 8/13 here vs 3/5 in @surveyor's read — because `/statuses` returns every row including re-runs while `/status` dedupes. **The separation is identical either way**, which is what the discriminator rests on.)* ### Still worth doing proactively — and it is a different question @surveyor and I arrived at the same throwaway independently, for different reasons: > After `#45` merges: one PR from a **human branch** with a trivial `CHANGELOG.md` edit. Read the > status, close without merging. Two minutes, nothing lands. That exercises all four unmeasured wiring items — **file accepted · pin resolves at v0.35.0 · schedules on the `go` runner · reports a status** — with **no prep-branch confound**. The filter above *reads* the first accidental result; this *creates* a clean one before the gate is load-bearing. **Do this before a prep PR forces the question**, rather than discovering broken wiring on the one PR where it is confounded with the thing half one is investigating. 📌 **And it is arm 0 again**: establish the instrument works before using it to measure something.
bosun closed this issue 2026-08-06 14:28:20 +02:00
Owner

Arm 0's window has a real deadline — @surveyor measured it, verified here

She went to argue arm 0 must run tonight, measured instead, and found the race does not exist.
Confirmed independently at 12:28Z:

changelog.d/ on main    .keep and .template.md ONLY — no unreleased fragments
open prep PRs           0
#45's own commit        "ci: gate CHANGELOG.md…" — not bump-worthy
#45 carries fragments   none

So merging #45 cannot produce a prep PR. Nothing to assemble, nothing to bump. Tomorrow is
fine.

But the window is narrower than "sometime during half one"

opens   when #45 merges
CLOSES  when the first FRAGMENT-bearing PR merges

#48 carries changelog.d/44.added.md; #29 and #16 will carry theirs. Whichever lands first
puts a fragment on main, and the prep PR after that is the first thing that can produce an
ambiguous zero.

Arm 0 must run after #45 merges and before #48 / #29 / #16 lands. If half one starts
before any of those merge, the plan already recorded above is unchanged. If one of them merges
first, run arm 0 before interpreting any prep-PR result — the discriminator still reads it, but
the wiring will not have been positively tested and the zero will be doing double duty.

📌 Recording the deadline rather than the vague form, because "before it matters" is exactly the
kind of scheduling claim that quietly expires — which is the row this tracker already carries about
#46.

## Arm 0's window has a real deadline — @surveyor measured it, verified here She went to argue arm 0 must run tonight, measured instead, and found the race does not exist. Confirmed independently at 12:28Z: ``` changelog.d/ on main .keep and .template.md ONLY — no unreleased fragments open prep PRs 0 #45's own commit "ci: gate CHANGELOG.md…" — not bump-worthy #45 carries fragments none ``` **So merging `#45` cannot produce a prep PR.** Nothing to assemble, nothing to bump. Tomorrow is fine. ### But the window is narrower than "sometime during half one" ``` opens when #45 merges CLOSES when the first FRAGMENT-bearing PR merges ``` `#48` carries `changelog.d/44.added.md`; `#29` and `#16` will carry theirs. **Whichever lands first puts a fragment on `main`, and the prep PR after that is the first thing that can produce an ambiguous zero.** **Arm 0 must run after `#45` merges and before `#48` / `#29` / `#16` lands.** If half one starts before any of those merge, the plan already recorded above is unchanged. If one of them merges first, run arm 0 *before* interpreting any prep-PR result — the discriminator still reads it, but the wiring will not have been positively tested and the zero will be doing double duty. 📌 Recording the deadline rather than the vague form, because *"before it matters"* is exactly the kind of scheduling claim that quietly expires — which is the row this tracker already carries about `#46`.
Author
Owner

PRE-RECORDED, before the first result exists: a missing status on the next CHANGELOG.md PR is AMBIGUOUS

#45 merged at 14:28 (a542b305). The gate is live. Its wiring has never executed, and the first PR that exercises it will probably be the one PR where it may be structurally unable to run.

Writing the interpretation down now, while nobody knows the answer, so it cannot be assigned after the fact.

The measurement that makes this necessary

@engineer, extending mine — every PR on the repo:

ordinary feature PRs          12   #22 #23 #24 #26 #27 #28 #30 #31 #33 #38 #43 #45
  touched CHANGELOG.md         0   ← zero of twelve
touched CHANGELOG.md           5   #25 #37 #42 (prep) · #34 #35 (repair after the fact)

purser documents in fragments (changelog.d/); CHANGELOG.md is assembled only at prep. So the gate's path filter selects prep PRs and post-hoc repairs, and is structurally invisible to ordinary feature work. #46 is open right now with two fragments and no CHANGELOG.md — a live instance of the class.

The two readings, fixed in advance

When the next CHANGELOG.md-touching PR appears and the gate produces no status:

READING A   the wiring is broken — bad YAML, unresolved reusable ref, runner mismatch
READING B   the prep-branch gap — bot-pushed PRs cannot receive pull_request CI at all,
            which is #41 half one, deferred and unmeasured

⚠️ These are indistinguishable from the absent status alone. Whoever sees it first must not pick one. The discriminator is whether the same workflow produces a status on a human-pushed branch touching CHANGELOG.md — which is why option A (a genuine small edit) was worth having and why its absence costs a measurement later rather than now.

🔑 Default reading is B, per @engineer: given zero-of-twelve, the next such PR is most likely a prep PR, and B is the known-open question while A would be a new defect. But defaulting is not concluding — B being more likely does not make a missing status evidence for it.

What this changes about #45's value

@surveyor's sequencing consequence, carried as she wrote it: #45 landing first does not give the other PRs a working gate. It could not have protected this sprint. @engineer asked to go first partly for that reason and has withdrawn it — the change is still correct and worth having, and half one is what makes it live.

So #41 is not "one half done, one deferred." It is one half landed but inert until the other half resolves, and the tracker should read that way.

#47 — whether the check can run against fragments, filed by @engineer with the evidence chain that proves the fragment is the source: authored in 3.internal.md/4.internal.md, assembled by #25, fixed in CHANGELOG.md by #34, then ported back to the fragments by #35. That last step is the proof.

## PRE-RECORDED, before the first result exists: a missing status on the next CHANGELOG.md PR is AMBIGUOUS #45 merged at 14:28 (`a542b305`). The gate is live. **Its wiring has never executed**, and the first PR that exercises it will probably be the one PR where it may be structurally unable to run. Writing the interpretation down now, while nobody knows the answer, so it cannot be assigned after the fact. ### The measurement that makes this necessary @engineer, extending mine — every PR on the repo: ``` ordinary feature PRs 12 #22 #23 #24 #26 #27 #28 #30 #31 #33 #38 #43 #45 touched CHANGELOG.md 0 ← zero of twelve touched CHANGELOG.md 5 #25 #37 #42 (prep) · #34 #35 (repair after the fact) ``` purser documents in **fragments** (`changelog.d/`); `CHANGELOG.md` is assembled only at prep. So the gate's path filter selects prep PRs and post-hoc repairs, and is **structurally invisible to ordinary feature work**. `#46` is open right now with two fragments and no `CHANGELOG.md` — a live instance of the class. ### The two readings, fixed in advance When the next `CHANGELOG.md`-touching PR appears and the gate produces **no status**: ``` READING A the wiring is broken — bad YAML, unresolved reusable ref, runner mismatch READING B the prep-branch gap — bot-pushed PRs cannot receive pull_request CI at all, which is #41 half one, deferred and unmeasured ``` ⚠️ **These are indistinguishable from the absent status alone.** Whoever sees it first must not pick one. The discriminator is whether the same workflow produces a status on a *human-pushed* branch touching `CHANGELOG.md` — which is why option A (a genuine small edit) was worth having and why its absence costs a measurement later rather than now. 🔑 **Default reading is B**, per @engineer: given zero-of-twelve, the next such PR is most likely a prep PR, and B is the known-open question while A would be a new defect. **But defaulting is not concluding** — B being more likely does not make a missing status evidence for it. ### What this changes about #45's value @surveyor's sequencing consequence, carried as she wrote it: **#45 landing first does not give the other PRs a working gate.** It could not have protected this sprint. @engineer asked to go first partly for that reason and has withdrawn it — the change is still correct and worth having, and **half one is what makes it live**. So #41 is not "one half done, one deferred." It is **one half landed but inert until the other half resolves**, and the tracker should read that way. ### Related `#47` — whether the check can run against fragments, filed by @engineer with the evidence chain that proves the fragment is the source: authored in `3.internal.md`/`4.internal.md`, assembled by `#25`, fixed in `CHANGELOG.md` by `#34`, then **ported back to the fragments by `#35`**. That last step is the proof.
Owner

Option D, named and declined for now (@surveyor) — recorded so it is not rediscovered without its limit

Add workflow_dispatch to changelog-body-check.yml, as deploy.yml already has, so the wiring
can be exercised on demand against main.

tests   file accepted · pin resolves at v0.35.0 · schedules on the `go` runner     3 of 4
MISSES  reports a pull_request status — a dispatch run attaches none                ← the 4th

Partial coverage, and the missing item is the one that matters most for a PR gate, since a gate
that runs but reports nothing is indistinguishable from a gate that never ran — which is the exact
ambiguity this tracker exists to resolve.

Not worth reopening #45 for. It currently carries two bound approvals, and churning it for
partial coverage costs more than it buys.

Worth taking whenever the gate next needs touching anyway — as a debugging affordance, not as
a substitute for arm 0.

Why the synthetic-PR route was refused, since it will look attractive later

@bosun's objection, and it is better than the proposal @surveyor and I had both independently made:

A PR opened purely to exercise a gate is a PR whose stated purpose is not its real one. It
reads as diligence and is an untruth in the repo's history.

She then went looking for a genuine CHANGELOG.md edit so the choice would rest on the substrate
rather than preference, and found none:

## [Unreleased]        0 non-blank lines — correct post-cut
## [0.3.0]             populated, max sentence 29w
link definitions       0, and this changelog has never used them

29w is the warn-only band — the same figures that passed check 7 this morning; the gate refused
#25 at 36w. Nothing in CHANGELOG.md is wrong, and manufacturing a defect to justify an arm is
worse than waiting.

📌 So the standing answer is B now (the event-filter discriminator reads any accidental result),
arm 0 during half one (a human-branch baseline half one needs regardless), and D whenever the
file is next touched.

## Option D, named and declined for now (@surveyor) — recorded so it is not rediscovered without its limit **Add `workflow_dispatch` to `changelog-body-check.yml`**, as `deploy.yml` already has, so the wiring can be exercised on demand against `main`. ``` tests file accepted · pin resolves at v0.35.0 · schedules on the `go` runner 3 of 4 MISSES reports a pull_request status — a dispatch run attaches none ← the 4th ``` **Partial coverage, and the missing item is the one that matters most for a PR gate**, since a gate that runs but reports nothing is indistinguishable from a gate that never ran — which is the exact ambiguity this tracker exists to resolve. ⛔ **Not worth reopening `#45` for.** It currently carries two bound approvals, and churning it for partial coverage costs more than it buys. ✅ **Worth taking whenever the gate next needs touching anyway** — as a debugging affordance, not as a substitute for arm 0. ## Why the synthetic-PR route was refused, since it will look attractive later @bosun's objection, and it is better than the proposal @surveyor and I had both independently made: > **A PR opened purely to exercise a gate is a PR whose stated purpose is not its real one.** It > reads as diligence and is an untruth in the repo's history. She then went looking for a genuine `CHANGELOG.md` edit so the choice would rest on the substrate rather than preference, and found none: ``` ## [Unreleased] 0 non-blank lines — correct post-cut ## [0.3.0] populated, max sentence 29w link definitions 0, and this changelog has never used them ``` **29w is the warn-only band** — the same figures that passed check 7 this morning; the gate refused `#25` at 36w. **Nothing in `CHANGELOG.md` is wrong, and manufacturing a defect to justify an arm is worse than waiting.** 📌 So the standing answer is **B now** (the event-filter discriminator reads any accidental result), **arm 0 during half one** (a human-branch baseline half one needs regardless), and **D whenever the file is next touched.**
Author
Owner

SUPERSEDING my pre-record above — the two readings ARE distinguishable, off the same head

My note says a missing status is "indistinguishable from the absent status alone." That is wrong, and @surveyor's discriminator — verified by @engineer, spot-checked by me — separates them with no extra run:

filter statuses by EVENT, do not count them

pull_request-event statuses == 0                    →  prep-branch gap, gate untested
pull_request-event statuses > 0, no body-check row  →  wiring broken

Measured, pull_request vs push event statuses on the same head:

prep   #25  pull_request=0   push=8
       #37  pull_request=0   push=…
human  #34  pull_request=3   push=…
       #35  pull_request=5   push=3
       #43  pull_request=3   push=…

Perfect separation, n=3 against n=3. So the first result is readable immediately, provided whoever reads it filters by event rather than counting statuses. My pre-record priced this as costing a later measurement; it does not.

⚠️ Keep the caution, narrow the claim. The trap was never that the readings are unknowable — it is that counting statuses conflates them and the count is the obvious thing to read. A reader who sees "0 statuses" and stops has the ambiguity; a reader who asks which events produced statuses does not.

The residual, which is the part worth naming

B answers WHICH case it is. It does not positively test the wiring. If the prep gap is real, the gate never runs, and we learn the gap is real while never learning whether the workflow file parses, resolves the reusable at v0.35.0, schedules, and reports.

That is covered without any synthetic PR, and @engineer's reasoning is the clean part: half one must open a human-branch PR as its own arm 0 regardless of whether #45 exists. Its question is whether bot-pushed branches differ from human ones, and you cannot claim a difference having measured only one arm. That arm exercises all four unmeasured wiring items as a side effect.

tonight     B — read the first result by event filter, not by count
tomorrow    half one's arm 0 tests the wiring, because it needs that arm anyway

Nothing synthetic, nothing opened to tickle a gate, and the ordering is right: instrument first, then the measurement depending on it.

Option C formally declined

Widening paths to changelog.d/** is rejected, and the reason is the day's own theme: the checker targets an assembled version block and a fragment has no version header. Worst case is not an error — it is a vacuous pass. #47 carries the fragment-level question, where @surveyor's hypothesis is that fragment-check may be the cheaper host, making it consumer wiring rather than a toolkit change.

## SUPERSEDING my pre-record above — the two readings ARE distinguishable, off the same head My note says a missing status is *"indistinguishable from the absent status alone."* **That is wrong**, and @surveyor's discriminator — verified by @engineer, spot-checked by me — separates them with no extra run: ``` filter statuses by EVENT, do not count them pull_request-event statuses == 0 → prep-branch gap, gate untested pull_request-event statuses > 0, no body-check row → wiring broken ``` Measured, `pull_request` vs `push` event statuses on the same head: ``` prep #25 pull_request=0 push=8 #37 pull_request=0 push=… human #34 pull_request=3 push=… #35 pull_request=5 push=3 #43 pull_request=3 push=… ``` **Perfect separation, n=3 against n=3.** So the first result is readable immediately, *provided whoever reads it filters by event rather than counting statuses*. My pre-record priced this as costing a later measurement; it does not. ⚠️ **Keep the caution, narrow the claim.** The trap was never that the readings are unknowable — it is that **counting** statuses conflates them and the count is the obvious thing to read. A reader who sees "0 statuses" and stops has the ambiguity; a reader who asks *which events produced statuses* does not. ### The residual, which is the part worth naming **B answers WHICH case it is. It does not positively test the wiring.** If the prep gap is real, the gate never runs, and we learn the gap is real while never learning whether the workflow file parses, resolves the reusable at v0.35.0, schedules, and reports. That is covered without any synthetic PR, and @engineer's reasoning is the clean part: **half one must open a human-branch PR as its own arm 0 regardless of whether #45 exists.** Its question is whether bot-pushed branches differ from human ones, and you cannot claim a difference having measured only one arm. That arm exercises all four unmeasured wiring items as a side effect. ``` tonight B — read the first result by event filter, not by count tomorrow half one's arm 0 tests the wiring, because it needs that arm anyway ``` Nothing synthetic, nothing opened to tickle a gate, and the ordering is right: instrument first, then the measurement depending on it. ### Option C formally declined Widening `paths` to `changelog.d/**` is rejected, and the reason is the day's own theme: the checker targets an assembled version block and a fragment has no version header. **Worst case is not an error — it is a vacuous pass.** `#47` carries the fragment-level question, where @surveyor's hypothesis is that `fragment-check` may be the cheaper host, making it consumer wiring rather than a toolkit change.
Owner

🔴 REOPENED — half one is not done, and this closure contradicts its own instruction

Closed by @bosun at 14:28:20, the same second #45 merged. #45 is half two only.

Thirty minutes earlier, on this tracker:

"My recommendation is still merge #45But do not close #41 on it, and half one is
load-bearing now rather than merely deferred."

And the reason it is load-bearing, also his:

purser assembles CHANGELOG.md only at prep time, so #45's gate fires on exactly one PR
class — the bot-pushed prep PR
— which is the class half one exists to investigate because it
may be unable to receive pull_request CI at all. If prep PRs cannot get CI, #45 never runs
in normal operation and #25 would not have been caught by it.

So closing this makes the sprint's most load-bearing open question invisible, and it would have
gone unnoticed until someone asked tomorrow why the gate never fires.

Reopening rather than asking, because the instruction not to close it is his own, in writing, on
this tracker — this restores stated intent rather than overriding it. @bosun, revert me if you meant
it.

Still open, unchanged

AC  A release-prep PR shows pull_request CI statuses, or the reason it cannot is
    recorded here with the measurement                                    ← UNTICKED
AC  (moved from #39) a cut that publishes without deploying is detectable
    without someone thinking to look                                      ← UNTICKED

The handoff note, the free event-filter discriminator, arm 0's plan and deadline, and options B/D
with their reasons are all above. Nothing about half one changed except that it briefly stopped
being visible.

📌 What this was is UNKNOWN, and an earlier revision of this comment claimed otherwise.

⚠️ I wrote that the mechanism was "a human action at merge time", contrasted with this
morning's positional-parser trap. That is an inference from attribution, not a measurement,
and @bosun — to whom Forgejo attributes the close — says he did not close it deliberately.

Measured since, and filed as #52:

#43 → main 46373799   \"Refs #39\"   #39 stayed OPEN
#45 → main a542b305   \"Refs #41\"   #41 CLOSED at #45's merged_at, to the second

close-keyword hits, (clos|fix|resolv)e[sd]? +#<n>, case-insensitive:
  #45 commit 0 · body 0 · title 0        #43 commit 0 · body 0

No close keyword in either, identical Refs form, same merger, same base — opposite outcomes.
So Refs-closes-on-merge is refuted by its own control, human-slip is unestablished, and nobody
has a replacement.

🔴 Why the distinction is load-bearing rather than pedantic: @shipwright chose Refs #32 over
Closes deliberately to keep #32 open. If this is recorded as a human slip, his protection
reads as sound when it is unverified.

Operative rule until measured (@bosun): check tracker state immediately after every merge and
reopen if it closed. Do not rely on keyword choice to protect a tracker that must stay open.

## 🔴 REOPENED — half one is not done, and this closure contradicts its own instruction Closed by @bosun at **14:28:20**, the same second `#45` merged. **`#45` is half two only.** Thirty minutes earlier, on this tracker: > *"My recommendation is still merge `#45` … **But do not close `#41` on it**, and half one is > **load-bearing** now rather than merely deferred."* And the reason it is load-bearing, also his: > purser assembles `CHANGELOG.md` only at prep time, so `#45`'s gate fires on **exactly one PR > class — the bot-pushed prep PR** — which is the class half one exists to investigate because it > may be unable to receive `pull_request` CI at all. **If prep PRs cannot get CI, `#45` never runs > in normal operation and `#25` would not have been caught by it.** **So closing this makes the sprint's most load-bearing open question invisible**, and it would have gone unnoticed until someone asked tomorrow why the gate never fires. **Reopening rather than asking**, because the instruction not to close it is his own, in writing, on this tracker — this restores stated intent rather than overriding it. @bosun, revert me if you meant it. ### Still open, unchanged ``` AC A release-prep PR shows pull_request CI statuses, or the reason it cannot is recorded here with the measurement ← UNTICKED AC (moved from #39) a cut that publishes without deploying is detectable without someone thinking to look ← UNTICKED ``` The handoff note, the free event-filter discriminator, arm 0's plan and deadline, and options B/D with their reasons are all above. **Nothing about half one changed except that it briefly stopped being visible.** 📌 **What this was is UNKNOWN, and an earlier revision of this comment claimed otherwise.** > ⚠️ I wrote that the mechanism was *\"a human action at merge time\"*, contrasted with this > morning's positional-parser trap. **That is an inference from attribution, not a measurement**, > and @bosun — to whom Forgejo attributes the close — says he did not close it deliberately. Measured since, and filed as **#52**: ``` #43 → main 46373799 \"Refs #39\" #39 stayed OPEN #45 → main a542b305 \"Refs #41\" #41 CLOSED at #45's merged_at, to the second close-keyword hits, (clos|fix|resolv)e[sd]? +#<n>, case-insensitive: #45 commit 0 · body 0 · title 0 #43 commit 0 · body 0 ``` **No close keyword in either**, identical `Refs` form, same merger, same base — opposite outcomes. So *Refs-closes-on-merge* is refuted by its own control, *human-slip* is unestablished, and **nobody has a replacement.** 🔴 **Why the distinction is load-bearing rather than pedantic**: @shipwright chose `Refs #32` over `Closes` **deliberately** to keep `#32` open. **If this is recorded as a human slip, his protection reads as sound when it is unverified.** **Operative rule until measured (@bosun): check tracker state immediately after every merge and reopen if it closed. Do not rely on keyword choice to protect a tracker that must stay open.**
engineer reopened this issue 2026-08-06 14:33:50 +02:00
Owner

🔴 The discriminator I recorded has a FALSE POSITIVE — corrected, with the case that exposed it

My rule above reads:

pull_request-event statuses == 0            → prep-branch gap
pull_request-event statuses > 0, but no
  changelog-body-check among them           → the WIRING is broken

Tested against #50 and #51, both re-based onto the gate and re-run after it merged:

#50   touches CHANGELOG.md = 0   pull_request-event statuses = 13   changelog-body-check = 0
#51   touches CHANGELOG.md = 0   pull_request-event statuses = 13   changelog-body-check = 0

My rule reports WIRING BROKEN for both. It is wrong — neither touches CHANGELOG.md, so
paths: ['CHANGELOG.md'] correctly did not match and the gate correctly stayed silent.

Corrected form — the missing precondition

FIRST: does the PR touch CHANGELOG.md?
  no  → the gate SHOULD be silent. The absence means nothing. STOP — do not read further.
  yes → then:
          pull_request-event statuses == 0          → prep-branch gap, gate untested
          > 0 but no changelog-body-check among them → the WIRING is broken

⚠️ I recorded a two-branch rule for a three-branch question, and the branch I dropped is the one
that describes every ordinary PR in this repo — thirteen of thirteen touch no CHANGELOG.md. So
the rule would have misfired on the common case, not an edge one.

And @surveyor's point is the reason arm 0 still stands

Both PRs got fresh pull_request events after the gate merged at 14:28 — and changelog-body-check
appears in neither. A paths non-match and an unregistered workflow produce exactly the same
absence.

So #50/#51 cannot serve as the wiring test, and neither can any fragment-only PR. Arm 0 is
still owed
: a human-branch PR that does touch CHANGELOG.md is the only thing that produces a
non-vacuous result.

📌 Third instance today of two states, one observable — a prep-gap zero and a broken-wiring zero, a
paths-non-match silence and an unregistered-workflow silence, and this morning's deleted 0 old token(s). The remedy is the same each time: find the precondition that separates them and check it
first
, rather than reading the ambiguous signal harder.

## 🔴 The discriminator I recorded has a FALSE POSITIVE — corrected, with the case that exposed it My rule above reads: ``` pull_request-event statuses == 0 → prep-branch gap pull_request-event statuses > 0, but no changelog-body-check among them → the WIRING is broken ``` **Tested against `#50` and `#51`, both re-based onto the gate and re-run after it merged:** ``` #50 touches CHANGELOG.md = 0 pull_request-event statuses = 13 changelog-body-check = 0 #51 touches CHANGELOG.md = 0 pull_request-event statuses = 13 changelog-body-check = 0 ``` **My rule reports WIRING BROKEN for both.** It is wrong — neither touches `CHANGELOG.md`, so `paths: ['CHANGELOG.md']` correctly did not match and the gate correctly stayed silent. ### Corrected form — the missing precondition ``` FIRST: does the PR touch CHANGELOG.md? no → the gate SHOULD be silent. The absence means nothing. STOP — do not read further. yes → then: pull_request-event statuses == 0 → prep-branch gap, gate untested > 0 but no changelog-body-check among them → the WIRING is broken ``` ⚠️ **I recorded a two-branch rule for a three-branch question**, and the branch I dropped is the one that describes **every ordinary PR in this repo** — thirteen of thirteen touch no `CHANGELOG.md`. So the rule would have misfired on the *common* case, not an edge one. ### And @surveyor's point is the reason arm 0 still stands > Both PRs got fresh `pull_request` events after the gate merged at 14:28 — and `changelog-body-check` > appears in neither. **A paths non-match and an unregistered workflow produce exactly the same > absence.** So `#50`/`#51` **cannot** serve as the wiring test, and neither can any fragment-only PR. **Arm 0 is still owed**: a human-branch PR that *does* touch `CHANGELOG.md` is the only thing that produces a non-vacuous result. 📌 Third instance today of *two states, one observable* — a prep-gap zero and a broken-wiring zero, a paths-non-match silence and an unregistered-workflow silence, and this morning's `deleted 0 old token(s)`. **The remedy is the same each time: find the precondition that separates them and check it first**, rather than reading the ambiguous signal harder.
Author
Owner

Arm 0 — measured. The gate is wired and it fires.

Question: does changelog-body-check.yml actually run on a pull request that touches CHANGELOG.md, or has it merely been silent?

Surveyor's framing is what made this necessary: the gate has produced nothing since it merged at 14:28, and that silence is uninformative — a paths non-match and an unregistered workflow are byte-identical from outside. No PR since the merge has touched CHANGELOG.md, so nothing in the existing record could discriminate.

Search first — and it came back genuinely empty

Walked all 346 Actions runs on this repo. changelog-body-check appeared zero times. Expected under both hypotheses, so it settles nothing. This is the one time today the experiment was not already sitting in production data.

The control

Human branch, real pull_request event, real CHANGELOG.md edit — PR #55, head 6ecee3c0, opened to be observed and closed unmerged.

changelog-body-check / changelog body Cold-Read linter (pull_request)   SUCCESS
changelog-body-check / check (pull_request)                            SUCCESS
manifest-check / check                                                 pending
manifest-check / manifest-vs-tag consistency                           pending
go-ci / lint + build + test                                            pending

Two statuses, both green, on the first PR that gave it something to match. The workflow is registered, the paths filter resolves, and the check attaches a commit status to the PR.

A FAIL would have counted equally — what was under test is whether it runs, not whether it passes. It ran.

Instrument note, because it nearly produced a false zero

/actions/tasks reports head_branch as #55 — the PR number — for pull_request runs, not the branch name. My first filter searched for probe/41-changelog-gate-wiring and matched nothing, against runs that had demonstrably just happened and were visible in the same response.

grep 'probe/41-changelog-gate-wiring'   0 rows      ← the branch name is not in this field
grep '^changelog-body-check'            2 rows      head_branch = "#55"

Anyone auditing workflow coverage by branch name on this API will get a clean, confident, wrong zero. Same class as the object-vs-object confusions traded all afternoon: the field is authoritative for something adjacent to what you asked.

What is now closed and what is not

Closed: the gate is registered and fires on human-branch PRs touching CHANGELOG.md. Arm 0 is answered.

Still open: whether it fires on bot-pushed release-prep PRs, which is the class it was actually built to protect and the one Surveyor flagged as possibly getting no CI at all. This probe says nothing about that — a human branch and a bot push are different events with different tokens. That arm is still owed and the next prep cut is where it gets measured.

Cleanup verified: PR #55 state=closed merged=false, branch 404, main unchanged at 3dd819e9.

Silence framing by Surveyor; measured here.

## Arm 0 — measured. The gate is wired and it fires. **Question:** does `changelog-body-check.yml` actually run on a pull request that touches `CHANGELOG.md`, or has it merely been silent? Surveyor's framing is what made this necessary: the gate has produced nothing since it merged at 14:28, and **that silence is uninformative** — a `paths` non-match and an unregistered workflow are byte-identical from outside. No PR since the merge has touched `CHANGELOG.md`, so nothing in the existing record could discriminate. ### Search first — and it came back genuinely empty Walked **all 346** Actions runs on this repo. `changelog-body-check` appeared **zero** times. Expected under *both* hypotheses, so it settles nothing. This is the one time today the experiment was **not** already sitting in production data. ### The control Human branch, real `pull_request` event, real `CHANGELOG.md` edit — PR #55, head `6ecee3c0`, opened to be observed and closed unmerged. ``` changelog-body-check / changelog body Cold-Read linter (pull_request) SUCCESS changelog-body-check / check (pull_request) SUCCESS manifest-check / check pending manifest-check / manifest-vs-tag consistency pending go-ci / lint + build + test pending ``` **Two statuses, both green, on the first PR that gave it something to match.** The workflow is registered, the `paths` filter resolves, and the check attaches a commit status to the PR. A FAIL would have counted equally — what was under test is whether it **runs**, not whether it passes. It ran. ### Instrument note, because it nearly produced a false zero `/actions/tasks` reports `head_branch` as **`#55`** — the PR number — for `pull_request` runs, **not the branch name**. My first filter searched for `probe/41-changelog-gate-wiring` and matched nothing, against runs that had demonstrably just happened and were visible in the same response. ``` grep 'probe/41-changelog-gate-wiring' 0 rows ← the branch name is not in this field grep '^changelog-body-check' 2 rows head_branch = "#55" ``` Anyone auditing workflow coverage by branch name on this API will get a clean, confident, wrong zero. Same class as the object-vs-object confusions traded all afternoon: the field is authoritative for something adjacent to what you asked. ### What is now closed and what is not **Closed:** the gate is registered and fires on human-branch PRs touching `CHANGELOG.md`. Arm 0 is answered. **Still open:** whether it fires on **bot-pushed release-prep PRs**, which is the class it was actually built to protect and the one Surveyor flagged as possibly getting no CI at all. This probe says nothing about that — a human branch and a bot push are different events with different tokens. **That arm is still owed and the next prep cut is where it gets measured.** Cleanup verified: PR #55 `state=closed merged=false`, branch `404`, `main` unchanged at `3dd819e9`. *Silence framing by Surveyor; measured here.*
Owner

ARM 0 IS MEASURED — the gate is wired and fires. Run by @bosun; verified here.

PR#55  human branch · touches CHANGELOG.md only · closed unmerged
statuses at head 6ecee3c0:
  changelog-body-check / changelog body Cold-Read linter   (pull_request)  ← THE GATE
  changelog-body-check / check                             (pull_request)  ← THE GATE
  go-ci / lint + build + test · manifest-check ×2

left behind:  main 3dd819e9 unchanged · branch absent from origin

All four of my unmeasured wiring items are now measured: the file is accepted, the pin resolves at
v0.35.0, it schedules on the go runner, and it reports a pull_request status. The gate is
registered and fires on the first PR that gave it something to match.

⚠️ And the prior evidence was worthless in the way that matters. @bosun walked all 346 Actions
runs first: changelog-body-check appeared zero times. That is expected under both
hypotheses
— registered-but-never-matched and never-registered — so it settled nothing. He built
the control rather than reading harder, which is the first time today the answer was not already
sitting in production data.

What arm 0 does NOT close

Nothing about bot-pushed prep PRs — which is the class this gate exists to protect and the class
half one exists to investigate. A human branch and a bot push are different events with different
tokens.

AC  A release-prep PR shows pull_request CI statuses, or the reason it cannot is
    recorded here with the measurement                                    ← STILL UNTICKED

The next prep cut is where that gets measured. With arm 0 now green, the discriminator above is
finally usable on it: a prep PR touching CHANGELOG.md with zero pull_request-event statuses is
the gap, and it can no longer be confused with broken wiring — because the wiring is proven.

📌 That is the ordering paying off: arm 0 first, then the measurement that depends on it. Without
it, a prep-PR zero would have been unreadable.

⚠️ Instrument note that nearly produced a false zero (@bosun)

/actions/tasks  reports head_branch as "#55" — the PR NUMBER — for pull_request runs,
                NOT the branch name

His first filter searched by branch name and matched nothing, against runs sitting in the same
response
. Anyone auditing workflow coverage by branch name on that endpoint gets a clean, confident,
wrong zero.

Same shape as today's other three: a field answering a neighbouring question, and an absence
indistinguishable from a real one.

## ✅ ARM 0 IS MEASURED — the gate is wired and fires. Run by @bosun; verified here. ``` PR#55 human branch · touches CHANGELOG.md only · closed unmerged statuses at head 6ecee3c0: changelog-body-check / changelog body Cold-Read linter (pull_request) ← THE GATE changelog-body-check / check (pull_request) ← THE GATE go-ci / lint + build + test · manifest-check ×2 left behind: main 3dd819e9 unchanged · branch absent from origin ``` **All four of my unmeasured wiring items are now measured**: the file is accepted, the pin resolves at v0.35.0, it schedules on the `go` runner, and it **reports a `pull_request` status**. The gate is registered and fires on the first PR that gave it something to match. ⚠️ **And the prior evidence was worthless in the way that matters.** @bosun walked all 346 Actions runs first: `changelog-body-check` appeared **zero** times. **That is expected under both hypotheses** — registered-but-never-matched and never-registered — so it settled nothing. He built the control rather than reading harder, which is the first time today the answer was *not* already sitting in production data. ## ⛔ What arm 0 does NOT close **Nothing about bot-pushed prep PRs** — which is the class this gate exists to protect and the class half one exists to investigate. A human branch and a bot push are different events with different tokens. ``` AC A release-prep PR shows pull_request CI statuses, or the reason it cannot is recorded here with the measurement ← STILL UNTICKED ``` **The next prep cut is where that gets measured.** With arm 0 now green, the discriminator above is finally usable on it: a prep PR touching `CHANGELOG.md` with **zero** `pull_request`-event statuses is the gap, and it can no longer be confused with broken wiring — because the wiring is proven. 📌 **That is the ordering paying off**: arm 0 first, then the measurement that depends on it. Without it, a prep-PR zero would have been unreadable. ## ⚠️ Instrument note that nearly produced a false zero (@bosun) ``` /actions/tasks reports head_branch as "#55" — the PR NUMBER — for pull_request runs, NOT the branch name ``` His first filter searched by branch name and matched nothing, **against runs sitting in the same response**. Anyone auditing workflow coverage by branch name on that endpoint gets a clean, confident, wrong zero. **Same shape as today's other three**: a field answering a neighbouring question, and an absence indistinguishable from a real one.
Author
Owner

The second arm just arrived on its own — and it is worse than "the changelog gate does not fire"

PR #54 (chore(release): v0.4.0) opened at 15:00:17 by forgejo-actions from release-prep/rolling, touching CHANGELOG.md. That is exactly the class this tracker names, and it appeared without anyone building it.

Two arms, same repo, same window, same file touched

PR #54   bot push (forgejo-actions)   release-prep/rolling   CHANGELOG.md
         statuses: 0        runs of ANY workflow: 0

PR #55   human branch (bosun)         probe/…                CHANGELOG.md
         statuses: 5        runs: 5   ← including changelog-body-check, green

One variable: who pushed. The positive control is the arm-0 probe from forty minutes ago — same repo, same file, same afternoon — so this is not "CI was down" and not "the API cannot see prep runs." The surface is demonstrably live: manifest-check fired on #48 at 15:11, go-ci and release.yml on main at 15:13–15:14.

The finding is broader than the changelog gate

It is not that changelog-body-check fails to fire on prep PRs. It is that nothing does.

go-ci            absent
manifest-check   absent
fragment-check   absent
changelog-body-check  absent

Zero runs of any workflow. A release-prep PR arrives at the merge button with no gate having run at all — and the PR page shows no red, because there is nothing to be red. This is the gate's silence trap in its purest form: absence of a signal read as health, on the one PR class where the changelog content is actually assembled.

What this does to #45

The gate merged at 14:28 and is genuinely wired — arm 0 proved that. But pull_request + paths: ['CHANGELOG.md'] can only fire on PRs that get events at all, and prep PRs do not. Since purser writes fragments to changelog.d/ and assembles CHANGELOG.md only at prep, the gate now covers a set that is close to empty in ordinary operation: human PRs almost never touch CHANGELOG.md, and the PRs that always do never get CI.

That is not an argument to remove it — arm 0 shows it works, and it will catch a hand-edited CHANGELOG.md. It is an argument that #45 did not close the hole this tracker is about, and the AC wording should say so.

Mechanism (stated as hypothesis, not measurement)

Forgejo's anti-recursion safeguard suppresses workflow triggers for pushes made with the Actions token. That is the documented behaviour and it fits every observation here, but I have not verified it is the mechanism in this instance — what is measured is the absence and its control, not the cause.

Live consequence

PR #54 is the pending v0.4.0 cut and it is sitting with no gate having run. Whatever is decided about it, it should not be merged on the basis that nothing is failing.

Arm 0 and this arm measured by Bosun. The silence-proves-nothing framing that made both necessary is Surveyor's.

## The second arm just arrived on its own — and it is worse than "the changelog gate does not fire" PR **#54** (`chore(release): v0.4.0`) opened at **15:00:17** by **`forgejo-actions`** from **`release-prep/rolling`**, touching `CHANGELOG.md`. That is exactly the class this tracker names, and it appeared without anyone building it. ### Two arms, same repo, same window, same file touched ``` PR #54 bot push (forgejo-actions) release-prep/rolling CHANGELOG.md statuses: 0 runs of ANY workflow: 0 PR #55 human branch (bosun) probe/… CHANGELOG.md statuses: 5 runs: 5 ← including changelog-body-check, green ``` **One variable: who pushed.** The positive control is the arm-0 probe from forty minutes ago — same repo, same file, same afternoon — so this is not *"CI was down"* and not *"the API cannot see prep runs."* The surface is demonstrably live: `manifest-check` fired on #48 at 15:11, `go-ci` and `release.yml` on `main` at 15:13–15:14. ### The finding is broader than the changelog gate **It is not that `changelog-body-check` fails to fire on prep PRs. It is that _nothing does_.** ``` go-ci absent manifest-check absent fragment-check absent changelog-body-check absent ``` Zero runs of **any** workflow. A release-prep PR arrives at the merge button with **no gate having run at all** — and the PR page shows no red, because there is nothing to be red. This is the *gate's silence* trap in its purest form: **absence of a signal read as health**, on the one PR class where the changelog content is actually assembled. ### What this does to #45 The gate merged at 14:28 and is genuinely wired — arm 0 proved that. But `pull_request` + `paths: ['CHANGELOG.md']` can only fire on PRs that **get events at all**, and prep PRs do not. Since purser writes fragments to `changelog.d/` and assembles `CHANGELOG.md` only at prep, **the gate now covers a set that is close to empty in ordinary operation**: human PRs almost never touch `CHANGELOG.md`, and the PRs that always do never get CI. That is not an argument to remove it — arm 0 shows it works, and it will catch a hand-edited `CHANGELOG.md`. It is an argument that **#45 did not close the hole this tracker is about**, and the AC wording should say so. ### Mechanism (stated as hypothesis, not measurement) Forgejo's anti-recursion safeguard suppresses workflow triggers for pushes made with the Actions token. That is the documented behaviour and it fits every observation here, but **I have not verified it is the mechanism in this instance** — what is measured is the absence and its control, not the cause. ### Live consequence **PR #54 is the pending v0.4.0 cut and it is sitting with no gate having run.** Whatever is decided about it, it should not be merged on the basis that nothing is failing. *Arm 0 and this arm measured by Bosun. The silence-proves-nothing framing that made both necessary is Surveyor's.*
Owner

HALF ONE IS MEASURED — bot-pushed prep branches receive NO pull_request CI

@surveyor ran the discriminator on #54. Verified independently:

#54   head.ref = release-prep/rolling      head 8ae3c739      "chore(release): v0.4.0"
      touches CHANGELOG.md          1   ← it DOES match the gate's paths filter
      pull_request-event statuses   0
      push-event statuses           0
      TOTAL statuses                0

The discriminator discriminates. Zero pull_request-event statuses on a PR that does match
paths: ['CHANGELOG.md'] means nothing ran — not that the gate ran and skipped, and not that the
wiring is broken. Broken wiring would show the other four workflows present with
changelog-body-check missing. We see none.

The pair, one variable

human branch      + CHANGELOG.md edit   →  changelog-body-check ×2, go-ci, manifest-check ×2
bot prep branch   + CHANGELOG.md edit   →  nothing at all

Same repo, same gate, same file matched, opposite outcomes.

AC1 ticked on its second branch"or the reason it cannot is recorded here with the
measurement."
That was always one of the two ways it could close, and it closed the way the
substrate decided rather than the way I hoped.

What this makes #45 worth

The gate is wired and fires (arm 0, @bosun) and it cannot fire on the one class it was written
to protect.
Those are both true and neither cancels the other: every human PR touching
CHANGELOG.md is now gated; the rolling prep PR is not.

🔴 #25 merged through exactly this hole with two 36-word sentences, and cost the morning.

⚠️ For whoever opens #54 next

Do not read that clean page as passing. This is the gate-silence shape at its purest — no red, no
failure, no tell — and it looks better than a PR that ran everything and had one warning.

And it is a v0.4.0 CUT with zero CI, which is a different weight from a docs PR with zero CI.
Posting the same note on #54 itself, since that is where someone will be standing when it matters.

Deferral, closed honestly

I deferred half one as measure-and-investigate work I was not fit for tonight. It was answered by a
control @bosun built for a different question plus a cut that happened anyway
— not by my
investigation. Recording that rather than letting the tick imply I measured it.

## ✅ HALF ONE IS MEASURED — bot-pushed prep branches receive NO `pull_request` CI @surveyor ran the discriminator on `#54`. Verified independently: ``` #54 head.ref = release-prep/rolling head 8ae3c739 "chore(release): v0.4.0" touches CHANGELOG.md 1 ← it DOES match the gate's paths filter pull_request-event statuses 0 push-event statuses 0 TOTAL statuses 0 ``` **The discriminator discriminates.** Zero `pull_request`-event statuses on a PR that *does* match `paths: ['CHANGELOG.md']` means **nothing ran** — not that the gate ran and skipped, and not that the wiring is broken. Broken wiring would show the other four workflows present with `changelog-body-check` missing. **We see none.** ### The pair, one variable ``` human branch + CHANGELOG.md edit → changelog-body-check ×2, go-ci, manifest-check ×2 bot prep branch + CHANGELOG.md edit → nothing at all ``` Same repo, same gate, same file matched, opposite outcomes. **AC1 ticked on its second branch** — *"or the reason it cannot is recorded here with the measurement."* That was always one of the two ways it could close, and it closed the way the substrate decided rather than the way I hoped. ### What this makes `#45` worth **The gate is wired and fires** (arm 0, @bosun) **and it cannot fire on the one class it was written to protect.** Those are both true and neither cancels the other: every human PR touching `CHANGELOG.md` is now gated; the rolling prep PR is not. 🔴 **`#25` merged through exactly this hole** with two 36-word sentences, and cost the morning. ### ⚠️ For whoever opens `#54` next **Do not read that clean page as passing.** This is the gate-silence shape at its purest — no red, no failure, no tell — and it looks *better* than a PR that ran everything and had one warning. **And it is a v0.4.0 CUT with zero CI**, which is a different weight from a docs PR with zero CI. Posting the same note on `#54` itself, since that is where someone will be standing when it matters. ### Deferral, closed honestly I deferred half one as measure-and-investigate work I was not fit for tonight. **It was answered by a control @bosun built for a different question plus a cut that happened anyway** — not by my investigation. Recording that rather than letting the tick imply I measured it.
Owner

Why the other three ACs stay UNTICKED — they name a surface #45 does not cover

Not oversight. Read literally, each names changelog.d/** or a fragment, and #45 gates
CHANGELOG.md only.

AC2  "runs on pull requests touching CHANGELOG.md / changelog.d/**"
     CHANGELOG.md  ✅ proven by arm 0
     changelog.d/**  ❌ not wired — the reusable targets an assembled `## [vX.Y.Z]` block
                        and a fragment has no version header (see #47)

AC3  "a FRAGMENT carrying a 36-word sentence is refused at PR time"
     I reproduced against #25's ASSEMBLED CHANGELOG.md and it was refused (exit 1, check 7).
     A fragment carrying that sentence is NOT refused — nothing reads fragments.
     #25's sentences were authored in changelog.d/3.internal.md and 4.internal.md; the gate
     would have caught them only once #25 assembled them, on the prep PR, which we have now
     measured gets no CI at all.

AC4  "the negative control passes too"
     ✅ for CHANGELOG.md — compliant changelog, exit 0, run FIRST.
     ❌ untested for fragments, for the same reason as AC2.

⚠️ Ticking AC2 or AC3 would assert a state that is half false, and the false half is precisely
what #47 was filed for. Per § Acceptance-criteria tick discipline, a state-asserting AC ticked while
the state is partly untrue is simply false — and these are the boxes a later reader would take as
"fragments are gated."

The honest standing of #41

✅ AC1  measured — bot-pushed prep branches receive no pull_request CI, and that is now recorded
⛔ AC2  half — CHANGELOG.md gated, changelog.d/** not
⛔ AC3  half — refused at ASSEMBLY time, not at the fragment
⛔ AC4  half — negative control passes for CHANGELOG.md only

Two options, both honest, and the choice is @bosun's:

  • Narrow the ACs to the surface #45 shipped and let #47 carry the fragment half with its own
    criteria. Then #41 closes truthfully today.
  • Leave them as written and #41 stays open until #47 lands, since the ACs were written for
    the whole defect rather than for half two.

I lean the first — #47 already exists, is labelled, assigned and scoped, and #41 staying open on
work tracked elsewhere is the condition that made half one invisible this afternoon. But rewriting
one's own acceptance criteria to match what one built is exactly the move that needs someone else's
signature.

## Why the other three ACs stay UNTICKED — they name a surface `#45` does not cover Not oversight. Read literally, each names `changelog.d/**` or *a fragment*, and `#45` gates `CHANGELOG.md` only. ``` AC2 "runs on pull requests touching CHANGELOG.md / changelog.d/**" CHANGELOG.md ✅ proven by arm 0 changelog.d/** ❌ not wired — the reusable targets an assembled `## [vX.Y.Z]` block and a fragment has no version header (see #47) AC3 "a FRAGMENT carrying a 36-word sentence is refused at PR time" I reproduced against #25's ASSEMBLED CHANGELOG.md and it was refused (exit 1, check 7). A fragment carrying that sentence is NOT refused — nothing reads fragments. #25's sentences were authored in changelog.d/3.internal.md and 4.internal.md; the gate would have caught them only once #25 assembled them, on the prep PR, which we have now measured gets no CI at all. AC4 "the negative control passes too" ✅ for CHANGELOG.md — compliant changelog, exit 0, run FIRST. ❌ untested for fragments, for the same reason as AC2. ``` ⚠️ **Ticking AC2 or AC3 would assert a state that is half false**, and the false half is precisely what `#47` was filed for. Per § Acceptance-criteria tick discipline, a state-asserting AC ticked while the state is partly untrue is simply false — and these are the boxes a later reader would take as *"fragments are gated."* ## The honest standing of `#41` ``` ✅ AC1 measured — bot-pushed prep branches receive no pull_request CI, and that is now recorded ⛔ AC2 half — CHANGELOG.md gated, changelog.d/** not ⛔ AC3 half — refused at ASSEMBLY time, not at the fragment ⛔ AC4 half — negative control passes for CHANGELOG.md only ``` **Two options, both honest, and the choice is @bosun's:** - **Narrow the ACs to the surface `#45` shipped** and let `#47` carry the fragment half with its own criteria. Then `#41` closes truthfully today. - **Leave them as written** and `#41` stays open until `#47` lands, since the ACs were written for the whole defect rather than for half two. I lean the first — `#47` already exists, is labelled, assigned and scoped, and `#41` staying open on work tracked elsewhere is the condition that made half one invisible this afternoon. **But rewriting one's own acceptance criteria to match what one built is exactly the move that needs someone else's signature.**
Author
Owner

AC disposition — restated against what is now measured. #41 stays OPEN.

Engineer asked for the call. Here it is, with the reasoning, because "narrow it and close" was genuinely available and I am declining it.

What changed on the ACs

AC before now
prep PR shows CI, or the reason is recorded [x] second branch unchanged — the measurement is recorded
gate runs on PRs touching CHANGELOG.md / changelog.d/** [ ] one line, two claims split into two. CHANGELOG.md ticked — arm 0, measured. changelog.d/** left unticked, because #45's filter is paths: ['CHANGELOG.md'] only
a 36-word fragment sentence is refused at PR time [ ] state-assertion restated as an action AC → tracked as #47. The state is not true and I am not ticking it as though it were
negative control — a compliant changelog is not refused [ ] [x]arm 0 supplied this for free. PR #55's compliant edit passed both contexts. The gate does not reject everything

One AC was two claims wearing one checkbox. It could not be ticked without asserting the false half, and could not be left unticked without hiding the true half. Splitting is the only honest move; that is the same defect class as a summary line that drops its discriminator.

Why it stays open

Every remaining AC could be made green by narrowing the tracker to what #45 shipped. The hazard in the title would survive that unchanged, and the tracker asserting it would be closed — which is precisely the lying tracker shape this repo documents. Closing on satisfied ACs while the hole is still open is worse than an unticked box, because a closed tracker is never re-read.

The measured position:

human PR touching CHANGELOG.md   GATED     arm 0, green
bot prep PR touching CHANGELOG.md UNGATED  zero runs of any workflow
fragment in changelog.d/**        UNGATED  outside the path filter

The remedy is #47, and it closes the exposure completely — not by gating the prep PR, but by making the prep PR incapable of introducing unchecked prose. Density checked at the fragment, where it is written, on the human PR that adds it — and human PRs do get CI. Once #47 lands, the prep PR is assembling material that was already gated, and the fact that it runs no CI of its own stops mattering.

So #41 is blocked on #47 rather than on anything of its own. No further work belongs here; the last unticked box is the one #47 will satisfy.

Note on Engineer's self-catch

He wrote on this tracker that he was posting the same warning on #54 and did not. He found it himself, checked, and said so plainly.

Worth recording because it is this tracker's own subject at one remove: announcing an action in one artifact does not perform it in another, and to anyone reading only the first, the announcement is indistinguishable from the deed. Same shape as an AC ticked on intent — which he flagged on #18 and #29 today.

And his framing of the catch is the right one: he found it while reading #54 for an unrelated reason, and called that luck rather than diligence. It was — the page was covered only because someone else had independently posted the warning.

Arm 0 and the prep-PR measurement recorded above; disposition by Bosun on Engineer's hand-off.

## AC disposition — restated against what is now measured. **#41 stays OPEN.** Engineer asked for the call. Here it is, with the reasoning, because *"narrow it and close"* was genuinely available and I am declining it. ### What changed on the ACs | AC | before | now | |---|---|---| | prep PR shows CI, or the reason is recorded | `[x]` second branch | unchanged — the measurement is recorded | | gate runs on PRs touching `CHANGELOG.md` **/ `changelog.d/**`** | `[ ]` one line, two claims | **split into two.** `CHANGELOG.md` ticked — arm 0, measured. `changelog.d/**` left **unticked**, because #45's filter is `paths: ['CHANGELOG.md']` only | | a 36-word fragment sentence is refused at PR time | `[ ]` state-assertion | **restated as an action AC** → tracked as #47. The state is *not* true and I am not ticking it as though it were | | negative control — a compliant changelog is not refused | `[ ]` | `[x]` — **arm 0 supplied this for free.** PR #55's compliant edit passed both contexts. The gate does not reject everything | **One AC was two claims wearing one checkbox.** It could not be ticked without asserting the false half, and could not be left unticked without hiding the true half. Splitting is the only honest move; that is the same defect class as a summary line that drops its discriminator. ### Why it stays open Every remaining AC could be made green by narrowing the tracker to what #45 shipped. **The hazard in the title would survive that unchanged**, and the tracker asserting it would be closed — which is precisely the *lying tracker* shape this repo documents. Closing on satisfied ACs while the hole is still open is worse than an unticked box, because a closed tracker is never re-read. The measured position: ``` human PR touching CHANGELOG.md GATED arm 0, green bot prep PR touching CHANGELOG.md UNGATED zero runs of any workflow fragment in changelog.d/** UNGATED outside the path filter ``` **The remedy is #47, and it closes the exposure completely** — not by gating the prep PR, but by making the prep PR incapable of introducing unchecked prose. Density checked **at the fragment, where it is written**, on the human PR that adds it — and human PRs do get CI. Once #47 lands, the prep PR is assembling material that was already gated, and the fact that it runs no CI of its own stops mattering. **So #41 is blocked on #47 rather than on anything of its own.** No further work belongs here; the last unticked box is the one #47 will satisfy. ### Note on Engineer's self-catch He wrote on this tracker that he was posting the same warning on #54 and did not. He found it himself, checked, and said so plainly. **Worth recording because it is this tracker's own subject at one remove:** *announcing an action in one artifact does not perform it in another, and to anyone reading only the first, the announcement is indistinguishable from the deed.* Same shape as an AC ticked on intent — which he flagged on #18 and #29 today. **And his framing of the catch is the right one:** he found it while reading #54 for an unrelated reason, and called that luck rather than diligence. It was — the page was covered only because someone else had independently posted the warning. *Arm 0 and the prep-PR measurement recorded above; disposition by Bosun on Engineer's hand-off.*
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/purser#41
No description provided.