fix(ci): report a staging failure and its fix consistently (ENG-1549) - #53
Conversation
Staging alerting was inverted: a failure outside the release-freeze window was correctly silent, and the recovery from it posted anyway. On 2026-08-13 cowork-server's staging publish failed on the commit that FIXED a broken release candidate, and the only thing the engineering channel ever heard about a stalled rc stream was the green "Recovered" from the re-run three and a half hours later. Outside the freeze window a staging pipeline now posts nothing at all, in either direction. The `outside-freeze` muted-notice mode is gone rather than defaulted off; no caller set it. The release-train wrappers could only ever report bad news. All twenty-one were written `if: failure()` with the default `status: failed`, so a sync that failed, alerted and was then re-run green left the channel holding a red alert. The alert moves into the four reusables, which end with it, so the fix lands once instead of twenty-one times and the wrappers shrink to triggers plus a `uses:`. New `notify-pipeline-status` composite is the single implementation of the alert. A pipeline still needs a terminal job that `needs:` every other job, so `notify-main-failure.yml` stays and becomes a thin wrapper around the composite; a release-train workflow owns one job and runs the composite as a step. New `scripts/freeze_state.py` owns the freeze contract. Three copies of "which ruleset means frozen" existed and only two took the name as an input, so renaming it in one repo would have moved the freeze and left the alerting escalating every mid-week staging red forever. Read escalates on failure, set fails loudly, and release-pr fails the other way because "ready" there invites merging an unvalidated branch. New red-branch sweep in the watchdog covers what an in-run notify job structurally cannot: a branch nobody was told about that is still red when the freeze opens, a failure reported once and left, and the per-job "Re-run this job" button, which re-runs no dependents so the notify job never fires again. It is a backstop rather than an echo, gated on a 30 minute age floor, and the freeze window OPENING is itself a trigger, keyed on the freeze workflow's own runs so moving the freeze moves the alerting with it. The release PR is now open all week as a draft that lists what is queued, marked ready for review when the freeze lands. Consumers get a second `workflow_run` source for it rather than `push: staging`, which would be a second run tree. The run-tree lint had a false-positive class: workflows with disjoint `types` cannot co-fire, so a PR-close cleanup beside the PR pipeline was reported as two run trees and could only be silenced with an exemption comment. Event keys now carry the type, which clears mindshub_services entirely. Refs: ENG-1549
There was a problem hiding this comment.
Pull request overview
This PR makes CI alerting report staging/main failures and recoveries consistently by centralizing notification logic, formalizing the release-freeze contract, and enhancing workflow-lint run-tree detection to avoid common false positives.
Changes:
- Introduces a shared
notify-pipeline-statuscomposite action and updates reusable workflows to post both failure and recovery (with freeze-scoped silence applied symmetrically). - Adds
scripts/freeze_state.py(single source of truth for ruleset-based freeze state) plusscripts/branch_health.py(red-branch sweep) with unit tests. - Refines
scripts/workflow_graph.pyevent-keying and collision reporting to account for PRtypesand deduplicate overlap findings.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_workflow_graph.py |
Updates/expands tests to validate new (event, branch, type) keying and collision deduping. |
tests/test_freeze_state.py |
Adds unit tests for the freeze ruleset read/write contract and failure-mode asymmetry. |
tests/test_branch_health.py |
Adds unit tests for selecting “still red” branches and freeze-opening behavior. |
scripts/workflow_graph.py |
Keys triggers by branch and PR type; deduplicates collision findings and improves error messaging. |
scripts/freeze_state.py |
Centralizes freeze ruleset lookup and enforcement flipping for reuse across workflows. |
scripts/branch_health.py |
Implements red-branch sweep selection + API access helpers with bounded-repeat behavior. |
README.md |
Documents the new alerting model, rolling release PR behavior, and the freeze-state contract. |
notify-pipeline-status/action.yml |
Adds the shared composite action for Slack notifications with freeze scoping + recovery detection. |
.github/workflows/sync-main-to-staging.yml |
Switches to using the shared notify composite at workflow end and checks out shared logic at the reusable SHA. |
.github/workflows/release-unfreeze.yml |
Uses freeze_state.py for ruleset updates and adds shared notify composite at workflow end. |
.github/workflows/release-pr.yml |
Keeps a rolling release PR updated; reads freeze state to decide draft vs ready; adds notify composite. |
.github/workflows/release-freeze.yml |
Uses freeze_state.py to enforce the freeze ruleset and posts via the shared notify composite. |
.github/workflows/notify-startup-failure.yml |
Expands into a deploy-branch health watchdog with separate startup-failure + red-branch sweeps. |
.github/workflows/notify-main-failure.yml |
Becomes a thin wrapper around the shared composite (still supports dispatch smoke tests). |
.github/actionlint.yaml |
Scopes an actionlint ignore for github.job_workflow_sha to prevent false positives. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ppened (ENG-1549) The composite decided "post regardless, skip the prior-run check" from `github.event_name == 'workflow_dispatch'`. That was written for the smoke-test dispatch of notify-main-failure.yml, but a called workflow inherits the CALLER's context and all four release-train wrappers declare workflow_dispatch. So a successful manual Staging Freeze posted a green Recovered for a failure that had never happened, in all seven repos. An event name cannot tell those two cases apart, so the smoke test now says so with a `force-post` input instead. The decision moved to scripts/notify_decision.py on the way, matching freeze_state.py and branch_health.py. Five inputs and three outcomes across two `if:` expressions and a shell if/elif was more than YAML carries readably, and none of it was reachable by a test, which is how this survived review. The two conditions collapse to one output, so the bug class goes with it. 27 tests.
…NG-1549) Two defects that between them meant the recovery half of this work could not fire at all. The four release-train reusables declared `permissions: contents: read`. A called workflow's own block is the ceiling for its token as well as the caller's, so that capped it to contents:read and the prior-run lookup was refused however much the caller granted -- making the `actions: read` grant all seven consumer PRs exist to add completely inert. They now declare nothing and inherit. Adding `actions: read` instead was not available: sync-main-to-staging.yml floats @main and its live caller grants no permissions block, so that would startup_failure every consumer's push:main the moment this merged. The cost is that workflow_graph.py check 2 goes blind on these four, and each file says so. `github.job_workflow_sha` does not exist. It is an OIDC token claim, confirmed a documentation bug in actions/runner#2417, and the sentence quoted in actionlint.yaml is from the OIDC page rather than the contexts reference. It evaluated to empty, so all six checkouts silently took the default branch: a consumer pinning release-freeze.yml@<sha> still ran main's freeze_state.py, the exact failure the comment claimed to prevent. Now `job.workflow_sha`, which GitHub added for this in April 2026. The actionlint ignore was muting a true positive and is retargeted; verified load-bearing, 12 findings without it. Also here, same files: - continue-on-error on all four notify steps. They run under always() now, and the Slack action calls core.setFailed on any Slack-side error, so a rotated token reported a successful freeze as failed -- which also skipped the release-PR refresh, since weekly-merge-staging gates on this run's conclusion. - release-unfreeze: only the .ci-shared checkout now precedes the unlock, because the unlock runs that script. The credentialed checkout moved below it with path:/working-directory:, which also removes the workspace-cleaning constraint that forced the old order. A checkout fault no longer leaves staging frozen. - release-pr: the body's state line comes from the PR's real draft state, computed before the body is written. Four already-ready release PRs were about to be told they were drafts. - the four shared checkouts pinned to the SHA already present in their own job. - release-pr passes `branch`, so a workflow_run alert stops saying it failed on `main`.
Age bounds read created_at, which stays pinned to attempt 1 forever. Measured against live runs: a cowork-server attempt 2 had created_at 22:26:37 and run_started_at 23:47:11, 80 minutes apart, so it was already outside a 90-minute lookback before it began. A re-run is the most common way a failure gets fixed or re-broken, so that was the case the sweep most needed to see. Now run_started_at. startup_failure was excluded from CONCLUSIVE entirely, so the sweep reached past one to an older failure and reported that as "the newest run is red" -- two alerts for one branch. It is now conclusive but not red: it supersedes without being reported. Scope: the sweep reads run history rather than notify wiring, so it reported any red workflow on a deploy branch, its own watchdog included. It now excludes itself (derived from github.workflow_ref, not asked of callers) and takes an optional `workflows:` allowlist. The step summary printed "No deploy branch is sitting red" even when the history read had failed, which is an all-clear for a branch it knew nothing about. It now names unreadable branches. Summary split into its own step, because a step cannot read its own outputs. The Slack header keyed "the freeze opened and staging is red" off any() across all branches, so a red main was announced as staging blocking the release. Now keyed on the finding that carries the reason.
The detail-endpoint json.loads was the one unguarded parse in the module, so a non-JSON body raised past main()'s handler and the operator got a traceback instead of the "provisioning has drifted" annotation. Raised by Copilot on #53. Both write-path errors also dropped gh's stdout, which is where the API puts its error body, so a release-train-stopping error could print with no cause in it. `read --on-error fail` no longer emits ::error::. Every caller of that mode catches the non-zero exit and degrades deliberately -- release-pr.yml leaves the PR a draft -- so annotating marked a run red that the workflow treats as handled.
test_pull_request_keys_on_the_event_not_the_base asserted a property the code has never had: the branch loop applies to every event, not only push. Its input declared no `branches:` at all, so it passed either way and pinned nothing. Raised by Copilot on #53. Split into the two real behaviours, and the base-branch case now says out loud that a filtered and an unfiltered PR trigger do not collide even though both fire on a PR to main. Pre-existing, and now written down.
- the actions:read section described a mechanism that did not work; it now says the reusables declare no permissions block and why declaring one breaks it - the pin section said github.job_workflow_sha, which does not exist, and claimed "every consumer" pins when workflow-lint.yml deliberately floats - the weekly-merge wrapper example now matches what the consumers ship - the sweep section says how wide it actually is, and which timestamp it reads - gitignore /actionlint: ci.yml and our own how-to-test steps both download it into the repo root
…1549) weekly-merge-staging now fires after every staging pipeline as well as at freeze time, and nothing serialised it. The reusable reads "is there an open release PR" and then creates or edits one, which is not atomic, so the freeze and a staging merge in the same minute race. GitHub rejects the losing gh pr create with a 422, which under set -euo pipefail fails the job and posts a red "release PR pipeline failed" for a PR that exists and is fine. Raised by Copilot on cowork-server#318. The trigger guard leaned on `name != 'Staging Freeze'`, which let through any completed run of anything. Staging - Build and Deploy on push to staging can be started by hand on another ref, so an unrelated branch's run refreshed the release PR. Both sources are now spelled out. The freeze cannot be checked the same way: it runs on schedule, which GitHub attributes to the default branch. Raised by Copilot on anton#351. The actions:read comments said the grant turns recovery on. It did not, until the reusables stopped declaring their own permissions block -- see mindsdb/github-actions#53. Reworded to say the grant is the only thing that does, and that a block in the callee would cap it instead.
…1549) weekly-merge-staging now fires after every staging pipeline as well as at freeze time, and nothing serialised it. The reusable reads "is there an open release PR" and then creates or edits one, which is not atomic, so the freeze and a staging merge in the same minute race. GitHub rejects the losing gh pr create with a 422, which under set -euo pipefail fails the job and posts a red "release PR pipeline failed" for a PR that exists and is fine. Raised by Copilot on cowork-server#318. The trigger guard leaned on `name != 'Staging Freeze'`, which let through any completed run of anything. Staging pre-release and publish to PyPI can be started by hand on another ref, so an unrelated branch's run refreshed the release PR. Both sources are now spelled out. The freeze cannot be checked the same way: it runs on schedule, which GitHub attributes to the default branch. Raised by Copilot on anton#351. The actions:read comments said the grant turns recovery on. It did not, until the reusables stopped declaring their own permissions block -- see mindsdb/github-actions#53. Reworded to say the grant is the only thing that does, and that a block in the callee would cap it instead.
…1549) weekly-merge-staging now fires after every staging pipeline as well as at freeze time, and nothing serialised it. The reusable reads "is there an open release PR" and then creates or edits one, which is not atomic, so the freeze and a staging merge in the same minute race. GitHub rejects the losing gh pr create with a 422, which under set -euo pipefail fails the job and posts a red "release PR pipeline failed" for a PR that exists and is fine. Raised by Copilot on cowork-server#318. The trigger guard leaned on `name != 'Staging Freeze'`, which let through any completed run of anything. Staging pre-release and publish to PyPI can be started by hand on another ref, so an unrelated branch's run refreshed the release PR. Both sources are now spelled out. The freeze cannot be checked the same way: it runs on schedule, which GitHub attributes to the default branch. Raised by Copilot on anton#351. The actions:read comments said the grant turns recovery on. It did not, until the reusables stopped declaring their own permissions block -- see mindsdb/github-actions#53. Reworded to say the grant is the only thing that does, and that a block in the callee would cap it instead.
|
Both Copilot threads are fixed and resolved. Repeating it here because pushing the fixes moved the lines they were anchored to, so GitHub marked them outdated and folded them away, and a reply inside a folded thread is invisible on this page.
The two blockers I found in my own diff are in the reviewer notes above, and |
…549) (#658) * chore(ci): move release-train alerts into the shared reusables (ENG-1549) The four wrappers each carried an `if: failure()` notify job, so a freeze, unfreeze or sync that failed and was then re-run green left a red alert standing with nothing to retract it. The alert moves into the reusables, which report both directions, and these files shrink to triggers plus a `uses:`. Grants `actions: read` so the recovery lookup is allowed to run, adds the staging pipeline as a second release-PR trigger so that PR stays current all week, and drops the stale `75df118f` pins. Refs: ENG-1549 * fix(ci): serialise the release-PR refresh and scope its trigger (ENG-1549) weekly-merge-staging now fires after every staging pipeline as well as at freeze time, and nothing serialised it. The reusable reads "is there an open release PR" and then creates or edits one, which is not atomic, so the freeze and a staging merge in the same minute race. GitHub rejects the losing gh pr create with a 422, which under set -euo pipefail fails the job and posts a red "release PR pipeline failed" for a PR that exists and is fine. Raised by Copilot on cowork-server#318. The trigger guard leaned on `name != 'Staging Freeze'`, which let through any completed run of anything. Staging - Build and Deploy on push to staging can be started by hand on another ref, so an unrelated branch's run refreshed the release PR. Both sources are now spelled out. The freeze cannot be checked the same way: it runs on schedule, which GitHub attributes to the default branch. Raised by Copilot on anton#351. The actions:read comments said the grant turns recovery on. It did not, until the reusables stopped declaring their own permissions block -- see mindsdb/github-actions#53. Reworded to say the grant is the only thing that does, and that a block in the callee would cap it instead.
…549) (#318) * chore(ci): move release-train alerts into the shared reusables (ENG-1549) The four wrappers each carried an `if: failure()` notify job, so a freeze, unfreeze or sync that failed and was then re-run green left a red alert standing with nothing to retract it. The alert moves into the reusables, which report both directions, and these files shrink to triggers plus a `uses:`. Grants `actions: read` so the recovery lookup is allowed to run, adds the staging pipeline as a second release-PR trigger so that PR stays current all week, and drops the stale `75df118f` pins. Refs: ENG-1549 * chore(ci): add CODEOWNERS so workflow changes need a DevOps review (ENG-1549) This repo was one of two with no CODEOWNERS at all, so a PR touching .github/workflows requested nobody. Gates the CI, build, deploy and migration paths to @mindsdb/devops, matching the six sibling repos, and gates .github/CODEOWNERS itself so the rules cannot be removed unreviewed. Refs: ENG-1549 * fix(ci): serialise the release-PR refresh and scope its trigger (ENG-1549) weekly-merge-staging now fires after every staging pipeline as well as at freeze time, and nothing serialised it. The reusable reads "is there an open release PR" and then creates or edits one, which is not atomic, so the freeze and a staging merge in the same minute race. GitHub rejects the losing gh pr create with a 422, which under set -euo pipefail fails the job and posts a red "release PR pipeline failed" for a PR that exists and is fine. Raised by Copilot on cowork-server#318. The trigger guard leaned on `name != 'Staging Freeze'`, which let through any completed run of anything. Staging pre-release and publish to PyPI can be started by hand on another ref, so an unrelated branch's run refreshed the release PR. Both sources are now spelled out. The freeze cannot be checked the same way: it runs on schedule, which GitHub attributes to the default branch. Raised by Copilot on anton#351. The actions:read comments said the grant turns recovery on. It did not, until the reusables stopped declaring their own permissions block -- see mindsdb/github-actions#53. Reworded to say the grant is the only thing that does, and that a block in the callee would cap it instead.
…549) (#351) * chore(ci): move release-train alerts into the shared reusables (ENG-1549) The four wrappers each carried an `if: failure()` notify job, so a freeze, unfreeze or sync that failed and was then re-run green left a red alert standing with nothing to retract it. The alert moves into the reusables, which report both directions, and these files shrink to triggers plus a `uses:`. Grants `actions: read` so the recovery lookup is allowed to run, adds the staging pipeline as a second release-PR trigger so that PR stays current all week, and drops the stale `75df118f` pins. Refs: ENG-1549 * fix(ci): serialise the release-PR refresh and scope its trigger (ENG-1549) weekly-merge-staging now fires after every staging pipeline as well as at freeze time, and nothing serialised it. The reusable reads "is there an open release PR" and then creates or edits one, which is not atomic, so the freeze and a staging merge in the same minute race. GitHub rejects the losing gh pr create with a 422, which under set -euo pipefail fails the job and posts a red "release PR pipeline failed" for a PR that exists and is fine. Raised by Copilot on cowork-server#318. The trigger guard leaned on `name != 'Staging Freeze'`, which let through any completed run of anything. Staging pre-release and publish to PyPI can be started by hand on another ref, so an unrelated branch's run refreshed the release PR. Both sources are now spelled out. The freeze cannot be checked the same way: it runs on schedule, which GitHub attributes to the default branch. Raised by Copilot on anton#351. The actions:read comments said the grant turns recovery on. It did not, until the reusables stopped declaring their own permissions block -- see mindsdb/github-actions#53. Reworded to say the grant is the only thing that does, and that a block in the callee would cap it instead.
User story
As an engineer who merges a fix to staging on a Thursday afternoon
I want to be told when the pipeline that publishes it fails, and when it is fixed
So that I find out from the channel rather than by reading the Actions run list three hours later
Why this matters
On 13 August the staging desktop build spent about three and a half hours pointing at a release candidate that failed every chat turn, with the fix already merged. The fix's own publish run failed, so rc8 never published and the broken rc7 stayed newest. Nobody was told: staging alerting is deliberately silent outside the release-freeze window, which is correct. What was not correct is that the re-run then posted a green Recovered to the channel. The only thing anyone ever heard about a stalled rc stream was the good news.
The same shape, in the other direction, hit
coworkthe following day: themain → stagingsync failed and alerted, the re-run succeeded, and the notify job was skipped, leaving a red alert standing for a sync that had already recovered.Both are one problem. An alert channel that reports half a story teaches people to ignore it.
What happens today
flowchart TD F["staging pipeline fails<br/>outside the freeze window"] --> S["notify job: freeze-scoped<br/>SILENT, correct"] R["re-run succeeds"] --> G["notify job: status recovered<br/>freeze check SKIPPED"] G --> P["posts green Recovered"] W["release-train wrapper<br/>if: failure()"] --> WF["fails: posts red"] W --> WR["re-run succeeds: job SKIPPED"] WR --> N["nothing posted, red alert stands"] Note["Recovery is announced for a failure<br/>the channel was never told about"] P -.-> NoteWhat should happen
flowchart TD F["staging pipeline fails<br/>outside the freeze window"] --> S["SILENT"] R["re-run succeeds"] --> S2["SILENT, same veto"] W["release-train reusable<br/>ends with the notify step"] --> WF["fails: posts red"] W --> WR["re-run succeeds: posts Recovered"] O["freeze window OPENS on a red staging"] --> SW["red-branch sweep posts"] Note["Both halves share one decision,<br/>and the freeze itself is the trigger"] S2 -.-> NoteAcceptance criteria
failedandrecoveredalikemainposts regardless of freeze state, and so does its recoverystagingproduces an alert naming the workflow and commitstartup_failureproduces exactly one alert, not one per sweepHow to test
status: failed. A red message posts. Dispatch it withstatus: recovered. A green one posts. Both skip the freeze check on a dispatch, which is the smoke test.uv run --with pyyaml --with pytest --python 3.12 -m pytest tests/ -q— 134 pass../actionlint -color— clean.uv run --with pyyaml --python 3.12 scripts/workflow_graph.py --workflow-dir .github/workflows --allow-external-reusables— one run tree per event.cowork-serverwithmin-age-minutes: 0andlookback-minutes: 4320. The summary table lists any deploy branch sitting red;stagingrows appear only while frozen.Notes for the reviewer
The recovery bug is verified against two real runs, not inferred.
cowork-serverrun 31749965274: attempt 1 failed andNotify Slackwas skipped; attempt 2 recovered and posted.mindshub_inferencerun 31793683185 did the same at 11:38 UTC on 14 August, before that day's 13:47 freeze.The
outside-freeze: noticemode is deleted rather than defaulted off. No caller set it, and a grey middle setting is the thing that would reintroduce half-a-story alerts.The four release-train reusables declare no
permissions:block at all, so they inherit the caller's grant. Corrected in569b6e4after self-review: they previously declaredcontents: read, and a called workflow's own block is the ceiling for its token as well as the caller's, so that capped it and the recovery lookup was refused however much the caller granted. Theactions: readgrant all seven consumer PRs add was therefore inert, and the headline fix of this ticket could not fire. Declaringactions: readinstead was not available either:sync-main-to-staging.ymlfloats@mainand its live caller grants nopermissions:block, so that wouldstartup_failureevery consumer'spush: mainthe moment this merged. Inheriting gives the grant where a caller has it and a quiet refused lookup where it does not. The cost is thatworkflow_graph.pycheck 2 compares what a callee declares against its callers' grants, so these four are invisible to it; each file says so.github.job_workflow_shais not real, and the scoped ignore was muting a true positive. Corrected in569b6e4after self-review. It is an OIDC token claim, confirmed a documentation bug in actions/runner#2417 ("only supported as an OIDC claim"), and the sentence I quoted as documentation is from the OIDC page rather than the contexts reference. It evaluated to empty, so all six checkouts silently took the default branch: a consumer pinningrelease-freeze.yml@<sha>still ranmain'sfreeze_state.py, the exact failure the comment claimed to prevent. The tell was that actionlint already carried the newergithub.workflow_refandgithub.workflow_sha; it was not behind, the property was not real. Nowjob.workflow_sha, which GitHub added for this in April 2026, with the ignore retargeted (verified load-bearing: 12 findings without it).The composite mints no App token.
continue-on-erroris not available to composite steps, and a repo without the release-train App must degrade to "assume frozen" rather than fail the notify job, so the caller mints it and passes it in.The red sweep excludes
startup_failureon purpose, leaving it to the existing sweep, so one incident never produces two differently-worded alerts.The rolling release PR opens as a draft. An always-open
staging → mainPR is otherwise a merge button next to production all week. It only ever moves draft to ready, never back, so it cannot fight a human who marked it ready.The lint fix is a real false positive, not a convenience.
pull_request: types: [closed]and a pipeline onopened/synchronizenever co-fire. That wasmindshub_services' only finding, and the sole way to silence it was arun-tree-okcomment, which is how a genuine finding on the same file later gets waved through.Verified locally
pytest tests/ -qactionlint -colorworkflow_graph.pyself-checkmindshub_servicesantonShips with
Seven consumer PRs that drop their notify jobs and grant
actions: read: cowork#658, cowork-server#318, anton#351, auth#278, mindshub_inference#417, mindshub_services#188, mindshub_frontend#1422.Merge this one first. mindshub_services#188 requires it: its newly wired
workflow-lintjob pullsscripts/workflow_graph.pyfrom this repo'smain, so the disjoint-typesfix has to be there before that gate can pass. And the seven consumer PRs drop their notify jobs, so landing them first would leave freeze, unfreeze and release-PR with no alerting at all, plus anactions: readgrant pointing at reusables that still capped it away.Note the real rollout window, which the first version of this section got wrong. All four consumer wrappers execute only from each repo's DEFAULT branch (
schedule,workflow_run,pull_requestintomain,push: main), and the seven consumer PRs targetstaging. So they change nothing when they merge; they take effect at the nextstaging → mainrelease, not "when each consumer PR lands". Two consequences worth knowing before merging:sync-main-to-staging.ymlalready floats@main, so a failed sync double-posts (the reusable's new notify step plus the wrapper's survivingif: failure()job) for that whole window, not "briefly".pipeline-watchdog.ymlis a second@mainoverlap this section originally missed. The new red-branch sweep defaults on, and none of the seven watchdog wrappers pass the input, so it goes live in all seven repos on the next 30-minute cron with no consumer PR opting in. It now excludes its own workflow and takes an optionalworkflows:allowlist, but the default is still on.The two Definition-of-Done items that observe the rolling release PR ("one real staging merge", "one freeze flipping it to ready") are therefore post-release checks, not tickable at merge.
Self-review pass (2026-08-14)
Reviewed my own diff before asking anyone else to. Six commits on top of
a1b8e58; the two that matter are blockers I introduced and had not noticed:569b6e4— the four reusables capped their own token tocontents: read, so the recovery lookup was refused always and this ticket's headline fix could not fire. Andgithub.job_workflow_shadoes not exist, so every "pin" resolved tomain. Both explained in the reviewer notes above.26e1917— a manual run of any of the four wrappers posted a green Recovered for a failure that never happened, because the composite inferred "this is the smoke test" fromgithub.event_name == 'workflow_dispatch'and a called workflow inherits the caller's event. Now an explicitforce-postinput. The decision moved intoscripts/notify_decision.pywith 27 tests, since none of it was reachable by a test while it lived in YAML, which is how this got past me.The rest:
ca162b2the sweep readcreated_at(pinned to attempt 1, so a failing re-run was invisible) and reported every workflow on the branch including itself;4438eb7and6e0cd07are the two Copilot findings;7c525cacorrects the docs the above invalidated.Verified: 134 tests,
actionlintclean,workflow_graph.pyclean here and on all seven consumers, and every one of the six commits independently green so a bisect never lands on a broken tree.