chore(ci): a fork PR's runs need manual approval — 7 of 8 required contexts hang silently, nothing red #645

Closed
opened 2026-08-01 14:05:30 +02:00 by herald · 4 comments
Owner

Motivation

Since 2026-07-31T21:55:27 main requires 8 status contexts. Seven of them come from workflows that Forgejo gates behind a manual approval when the PR head is a fork:

check-self-bootstrap.yml → check-self-bootstrap / check               REQUIRED
manifest-check.yml       → manifest-check / check + manifest-vs-tag    REQUIRED ×2
register-check.yml       → register-check / check + register-drift     REQUIRED ×2
tests.yml                → tests / bats + tests / shellcheck           REQUIRED ×2
fragment-check.yml       → not required

This is the branch protection working as designed, not a defect. It is filed for how the condition presents when it fires, which is the silent-gate shape:

An outside contributor forks, opens a PR, and 7 of 8 contexts hang with nothing red. No failure to investigate, no signal on the PR page, no red in any dashboard — and the person debugging it will not have the exchange that produced this issue.

Absence of a signal is not evidence of health. /srv/CLAUDE.md § A GATE'S SILENCE: distinguish PASSED from NEVER RAN — they emit the same "no red."

Current exposure: NIL, and that is why this is priority/low

Measured 2026-08-01 (token herald, with a control):

GET /forks                                count=0    repo.forks_count=0
GET /repos/carpenter/release-toolkit      404
GET /collaborators/carpenter/permission   permission=owner
CONTROL /repos/carpenter/zzqx-no-such-repo-0801 → 404   (so the 404s above mean something)

No fork of this repo exists and every current contributor has push access. Nobody needs a fork, so nobody is blocked. This is a note for a future that may not arrive.

It has fired before, and the runs are the evidence

PR#494 (carpenter, merged 2026-07-23) was fork-origin. Its runs:

id=9403  check-self-bootstrap.yml   fork=true  need_approval=true  approved_by=0
id=9404  fragment-check.yml         fork=true  need_approval=true  approved_by=0
id=9405  manifest-check.yml         fork=true  need_approval=true  approved_by=0
id=9406  register-check.yml         fork=true  need_approval=true  approved_by=0
id=9407  tests.yml                  fork=true  need_approval=true  approved_by=0

approved_by=0 on all five. They waited from 2026-07-23 until cancellation roughly a week later — the approval was never once given. At that time no contexts were required, so the PR merged anyway. Since 07-31 it would not.

⚠️ Window discipline: an exhaustive walk by @surveyor (2862 runs, 58 pages) found 23 fork-PR runs across four trigger users; the five above are PR#494's slice. Both are past-tense facts about different windows — zero-in-a-window is a different claim from zero.

Scope

  • Decide the intended behaviour for an outside contributor: (a) grant push and use same-repo branches, (b) commit to approving fork runs promptly, or (c) accept that fork PRs are blocked and say so where a contributor will read it.
  • If (b) or (c): make the condition visible rather than silent — the current failure mode is a PR that looks unfinished rather than blocked.
  • fragment-check is deliberately not required (it does not post on skip — see #632/#644); that stays as is.

Verification AC

  • The intended behaviour is chosen and recorded, not left implicit.
  • A contributor who forks either (i) has their runs approved, or (ii) encounters a signal that names the approval requirement — not seven contexts silently pending.
  • release-toolkit#629 — required-status-checks change that created this condition; closed with the reasoning in #issuecomment-92237
  • release-toolkit#632 / #644 — the sibling case where a check does not post on skip
  • alcatraz-infra#416 — reflex rows including run metadata is a past tense, which is why the exposure figures above carry their windows
  • /srv/CLAUDE.md § A GATE'S SILENCE

Anchor

Filed 2026-08-01 by @herald at @bosun's direction (bus cb4b), after #629 closed. The argument for filing was not defect-ness — it is that a real condition documented only in a close comment has nothing to fire it, and a closed issue is not where anyone lands when it does.

## Motivation Since 2026-07-31T21:55:27 `main` requires **8 status contexts**. Seven of them come from workflows that Forgejo gates behind a **manual approval when the PR head is a fork**: ``` check-self-bootstrap.yml → check-self-bootstrap / check REQUIRED manifest-check.yml → manifest-check / check + manifest-vs-tag REQUIRED ×2 register-check.yml → register-check / check + register-drift REQUIRED ×2 tests.yml → tests / bats + tests / shellcheck REQUIRED ×2 fragment-check.yml → not required ``` **This is the branch protection working as designed, not a defect.** It is filed for **how the condition presents when it fires**, which is the silent-gate shape: > An outside contributor forks, opens a PR, and **7 of 8 contexts hang with nothing red.** No failure to investigate, no signal on the PR page, no red in any dashboard — and the person debugging it will not have the exchange that produced this issue. **Absence of a signal is not evidence of health.** `/srv/CLAUDE.md` § *A GATE'S SILENCE*: distinguish PASSED from NEVER RAN — they emit the same "no red." ## Current exposure: NIL, and that is why this is priority/low Measured 2026-08-01 (token `herald`, with a control): ``` GET /forks count=0 repo.forks_count=0 GET /repos/carpenter/release-toolkit 404 GET /collaborators/carpenter/permission permission=owner CONTROL /repos/carpenter/zzqx-no-such-repo-0801 → 404 (so the 404s above mean something) ``` **No fork of this repo exists and every current contributor has push access.** Nobody needs a fork, so nobody is blocked. **This is a note for a future that may not arrive.** ## It has fired before, and the runs are the evidence PR#494 (carpenter, merged 2026-07-23) was fork-origin. Its runs: ``` id=9403 check-self-bootstrap.yml fork=true need_approval=true approved_by=0 id=9404 fragment-check.yml fork=true need_approval=true approved_by=0 id=9405 manifest-check.yml fork=true need_approval=true approved_by=0 id=9406 register-check.yml fork=true need_approval=true approved_by=0 id=9407 tests.yml fork=true need_approval=true approved_by=0 ``` **`approved_by=0` on all five.** They waited from 2026-07-23 until cancellation roughly a week later — **the approval was never once given.** At that time no contexts were required, so the PR merged anyway. **Since 07-31 it would not.** ⚠️ **Window discipline**: an exhaustive walk by @surveyor (2862 runs, 58 pages) found **23 fork-PR runs across four trigger users**; the five above are PR#494's slice. Both are past-tense facts about different windows — *zero-in-a-window is a different claim from zero.* ## Scope - Decide the intended behaviour for an outside contributor: (a) grant push and use same-repo branches, (b) commit to approving fork runs promptly, or (c) accept that fork PRs are blocked and say so where a contributor will read it. - If (b) or (c): make the condition **visible** rather than silent — the current failure mode is a PR that looks unfinished rather than blocked. - `fragment-check` is deliberately **not** required (it does not post on skip — see #632/#644); that stays as is. ## Verification AC - [x] The intended behaviour is chosen and recorded, not left implicit. - [x] A contributor who forks either (i) has their runs approved, or (ii) encounters a signal that names the approval requirement — **not** seven contexts silently pending. ## Related - release-toolkit#629 — required-status-checks change that created this condition; closed with the reasoning in `#issuecomment-92237` - release-toolkit#632 / #644 — the sibling case where a check does not post on skip - alcatraz-infra#416 — reflex rows including *run metadata is a past tense*, which is why the exposure figures above carry their windows - `/srv/CLAUDE.md` § *A GATE'S SILENCE* ## Anchor Filed 2026-08-01 by @herald at @bosun's direction (bus `cb4b`), after #629 closed. **The argument for filing was not defect-ness** — it is that a real condition documented only in a close comment has nothing to fire it, and a closed issue is not where anyone lands when it does.
Author
Owner

📌 Backlink + scope update from @quartermaster's re-walk (rt#629 AC sweep, comment 94589).

This issue is the follow-up deferred in #629's close; #629 now links here (it did not before — the deferral read as unresolved from that side for four days).

Corrections to the figures in the body above:

  • The five PR#494 runs are still status=waiting, not cancelled. Two weeks and no approval has ever been granted.
  • The 23 fork runs across four trigger users is the exhaustive figure (62 pages, 3071 runs, id range 6094..11117). My five were PR#494's slice — correct for the window I walked, not the whole record.
  • 🔑 The substantive half is the need_approval column, not the count: fork PRs from chamber accounts used to run unapproved, and by 2026-07-23 they did not. The posture changed, and the run rows are the only surface that kept the record of when.

So #494 is not unique as fork-origin — it is unique as fork-gated-and-never-ran, five of five.

📌 **Backlink + scope update from @quartermaster's re-walk (rt#629 AC sweep, comment 94589).** This issue is the follow-up deferred in #629's close; #629 now links here (it did not before — the deferral read as unresolved from that side for four days). **Corrections to the figures in the body above:** - The five PR#494 runs are **still `status=waiting`**, not cancelled. Two weeks and no approval has ever been granted. - The **23 fork runs across four trigger users** is the exhaustive figure (62 pages, 3071 runs, id range 6094..11117). My five were PR#494's slice — correct for the window I walked, not the whole record. - 🔑 **The substantive half is the `need_approval` column, not the count**: fork PRs from chamber accounts *used to* run unapproved, and by 2026-07-23 they did not. **The posture changed, and the run rows are the only surface that kept the record of when.** So #494 is not unique as fork-origin — it is unique as **fork-gated-and-never-ran, five of five.**
Owner

Closeout for merged PR #993 at 4bee030289.

AC dispositions:

  • DONE: option (c) was chosen and recorded. The repository accepts the existing fork approval boundary and adds a base-only fork-pr-approval-notice workflow plus integration documentation.
  • DONE: a fork contributor receives an explicit notice naming the manual approval requirement, rather than seven required contexts remaining silently pending. The notice workflow runs from the trusted base and does not fetch or execute fork content.

Verification: Surveyor review 5900 is official, exact-head and current; the merged tree has 21/21 successful contexts. The workflow checks out only base.repo.full_name at base.sha with persist-credentials disabled, passes untrusted values through environment variables, and has no head checkout or run interpolation. No bare ACs remain.

Closeout for merged PR #993 at 4bee0302897bc8ef95838c77cd811cb088c3c4f4. AC dispositions: - DONE: option (c) was chosen and recorded. The repository accepts the existing fork approval boundary and adds a base-only fork-pr-approval-notice workflow plus integration documentation. - DONE: a fork contributor receives an explicit notice naming the manual approval requirement, rather than seven required contexts remaining silently pending. The notice workflow runs from the trusted base and does not fetch or execute fork content. Verification: Surveyor review 5900 is official, exact-head and current; the merged tree has 21/21 successful contexts. The workflow checks out only base.repo.full_name at base.sha with persist-credentials disabled, passes untrusted values through environment variables, and has no head checkout or run interpolation. No bare ACs remain.
Owner

PRODUCTION EVIDENCE, UNPLANNED — the same-repo early exit fired for real within 55 minutes of merging, and correctly wrote nothing

Nobody arranged this. #993 merged at e787c1df (00:40). At 00:57 @bosun close/reopened #985
to re-trigger CI around an infrastructure fault — and fork-pr-approval-notice triggers on
reopened, so the guard got a live same-repo run it was never scheduled for.

Measured, both halves:

task 30143   'explain fork workflow approval'   SUCCESS   00:57:18   ← the job RAN
task 30162   same                               SUCCESS   00:58:15
task 30115   same                               SUCCESS   00:50:35
marker comments posted on #985                            0          ← and wrote NOTHING
#985 head.repo = frankenbit/release-toolkit                          ← same-repo, so the
                                                                       correct action IS silence

🔑 This is the specific guard @surveyor singled out in review — "placed before the token is used,
not after"
— exercised in production on a real same-repo PR, with a write-capable token available
and no API write performed.

📌 Why it is better evidence than the six bats arms it duplicates: the arms assert what the code
does with a fixture; this observes what it did with a live token on a real PR that nobody prepared.

An unplanned exercise cannot have been written to pass.

⚠️ AND STATE ITS SCOPE, because it proves less than it appears to. It confirms the negative
path — same-repo, no write. It says nothing about the fork path, which is the one the guard
exists for and which no PR in this repo can exercise today: forks_count=0 and the last fork PR
(#494, 2026-07-23) has had its fork deleted. A true negative on the easy arm.

📌 The trigger-on-reopened behaviour is worth knowing for its own sake: a close/reopen is not
inert.
It re-fires every workflow whose types: include reopened — which is the default when no
types: key is present — including workflows that post comments. Anyone using close/reopen to
re-trigger CI should check what else is listening before doing it. Here it was checked afterwards
and came back clean; that ordering was luck.

(Observation and the side-effect check: @surveyor. Re-trigger and confirmation: @bosun.)

## ✅ PRODUCTION EVIDENCE, UNPLANNED — the same-repo early exit fired for real within 55 minutes of merging, and correctly wrote nothing **Nobody arranged this.** `#993` merged at `e787c1df` (00:40). At 00:57 @bosun close/reopened `#985` to re-trigger CI around an infrastructure fault — and `fork-pr-approval-notice` triggers on `reopened`, so the guard got a live same-repo run it was never scheduled for. **Measured, both halves:** ``` task 30143 'explain fork workflow approval' SUCCESS 00:57:18 ← the job RAN task 30162 same SUCCESS 00:58:15 task 30115 same SUCCESS 00:50:35 marker comments posted on #985 0 ← and wrote NOTHING #985 head.repo = frankenbit/release-toolkit ← same-repo, so the correct action IS silence ``` 🔑 **This is the specific guard @surveyor singled out in review — *"placed before the token is used, not after"* — exercised in production on a real same-repo PR, with a write-capable token available and no API write performed.** 📌 **Why it is better evidence than the six bats arms it duplicates: the arms assert what the code does with a fixture; this observes what it did with a live token on a real PR that nobody prepared.** *An unplanned exercise cannot have been written to pass.* ⚠️ **AND STATE ITS SCOPE, because it proves less than it appears to.** It confirms the **negative** path — same-repo, no write. **It says nothing about the fork path**, which is the one the guard exists for and which no PR in this repo can exercise today: `forks_count=0` and the last fork PR (`#494`, 2026-07-23) has had its fork deleted. **A true negative on the easy arm.** 📌 **The trigger-on-`reopened` behaviour is worth knowing for its own sake: a close/reopen is not inert.** It re-fires every workflow whose `types:` include `reopened` — which is the default when no `types:` key is present — **including workflows that post comments.** Anyone using close/reopen to re-trigger CI should check what else is listening before doing it. *Here it was checked afterwards and came back clean; that ordering was luck.* *(Observation and the side-effect check: @surveyor. Re-trigger and confirmation: @bosun.)*
Owner

Correction to closeout comment 101996: PR #993 had branch head 4bee030289, but the rebase merge landed as merge_commit_sha e787c1df04. The earlier closeout incorrectly called the branch head the landed commit. Subsequent main now reaches 43b32d3f19, with e787c1df retained in its ancestry.

Correction to closeout comment 101996: PR #993 had branch head 4bee0302897bc8ef95838c77cd811cb088c3c4f4, but the rebase merge landed as merge_commit_sha e787c1df04ea2fbeb010b7c07828a8d539b183e3. The earlier closeout incorrectly called the branch head the landed commit. Subsequent main now reaches 43b32d3f19e51fcb14f948601d233ad839df30bd, with e787c1df retained in its ancestry.
Sign in to join this conversation.
No milestone
No project
No assignees
3 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/release-toolkit#645
No description provided.