Skip to content

fix(ci): report a staging failure and its fix consistently (ENG-1549) - #53

Merged
lucas-koontz merged 7 commits into
mainfrom
fix/eng-1549-freeze-scoped-alerting
Aug 14, 2026
Merged

fix(ci): report a staging failure and its fix consistently (ENG-1549)#53
lucas-koontz merged 7 commits into
mainfrom
fix/eng-1549-freeze-scoped-alerting

Conversation

@lucas-koontz

@lucas-koontz lucas-koontz commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 cowork the following day: the main → staging sync 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 -.-> Note
Loading

What 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 -.-> Note
Loading

Acceptance criteria

  • A freeze-scoped staging caller posts nothing outside the freeze window, for failed and recovered alike
  • A freeze-scoped staging caller posts both a failure and its recovery while the window is open
  • A failure on main posts regardless of freeze state, and so does its recovery
  • A release-train workflow that fails, and is then re-run successfully, posts both messages
  • The freeze window opening on an already-red staging produces an alert naming the workflow and commit
  • A green run whose previous run was also green still posts nothing
  • A startup_failure produces exactly one alert, not one per sweep
  • Renaming a repo's freeze ruleset changes one input, not four files
  • A PR-close cleanup workflow beside a PR pipeline is not reported as two run trees

How to test

  1. From this repo's Actions tab, dispatch Notify main-branch failure with status: failed. A red message posts. Dispatch it with status: recovered. A green one posts. Both skip the freeze check on a dispatch, which is the smoke test.
  2. uv run --with pyyaml --with pytest --python 3.12 -m pytest tests/ -q — 134 pass.
  3. ./actionlint -color — clean.
  4. uv run --with pyyaml --python 3.12 scripts/workflow_graph.py --workflow-dir .github/workflows --allow-external-reusables — one run tree per event.
  5. After merge, dispatch Pipeline watchdog on cowork-server with min-age-minutes: 0 and lookback-minutes: 4320. The summary table lists any deploy branch sitting red; staging rows appear only while frozen.
  6. Re-run a single failed job (the per-job button, not "Re-run failed jobs") on any pipeline and confirm no notify fires, then confirm the watchdog reports it 30 minutes later. This is the gap the sweep exists for.

Notes for the reviewer

The recovery bug is verified against two real runs, not inferred. cowork-server run 31749965274: attempt 1 failed and Notify Slack was skipped; attempt 2 recovered and posted. mindshub_inference run 31793683185 did the same at 11:38 UTC on 14 August, before that day's 13:47 freeze.

The outside-freeze: notice mode 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 in 569b6e4 after self-review: they previously declared contents: 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. The actions: read grant all seven consumer PRs add was therefore inert, and the headline fix of this ticket could not fire. Declaring actions: read instead was not available either: 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. Inheriting gives the grant where a caller has it and a quiet refused lookup where it does not. The cost is that workflow_graph.py check 2 compares what a callee declares against its callers' grants, so these four are invisible to it; each file says so.

github.job_workflow_sha is not real, and the scoped ignore was muting a true positive. Corrected in 569b6e4 after 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 pinning release-freeze.yml@<sha> still ran main's freeze_state.py, the exact failure the comment claimed to prevent. The tell was that actionlint already carried the newer github.workflow_ref and github.workflow_sha; it was not behind, the property was not real. Now job.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-error is 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_failure on 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 → main PR 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 on opened/synchronize never co-fire. That was mindshub_services' only finding, and the sole way to silence it was a run-tree-ok comment, which is how a genuine finding on the same file later gets waved through.

Verified locally

Check Result
pytest tests/ -q 134 passed (was 61; 73 new across five files)
actionlint -color clean
workflow_graph.py self-check one run tree per event, every local call composes
Consumer lint, 5 repos clean
Consumer lint, mindshub_services 1 finding before, 0 after
Consumer lint, anton 3 findings before, 2 after; both pre-existing and untouched here
Slack post path not exercised locally; needs the dispatch in step 1 after merge

Ships 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-lint job pulls scripts/workflow_graph.py from this repo's main, so the disjoint-types fix 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 an actions: read grant 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_request into main, push: main), and the seven consumer PRs target staging. So they change nothing when they merge; they take effect at the next staging → main release, not "when each consumer PR lands". Two consequences worth knowing before merging:

  • sync-main-to-staging.yml already floats @main, so a failed sync double-posts (the reusable's new notify step plus the wrapper's surviving if: failure() job) for that whole window, not "briefly".
  • pipeline-watchdog.yml is a second @main overlap 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 optional workflows: 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 to contents: read, so the recovery lookup was refused always and this ticket's headline fix could not fire. And github.job_workflow_sha does not exist, so every "pin" resolved to main. 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" from github.event_name == 'workflow_dispatch' and a called workflow inherits the caller's event. Now an explicit force-post input. The decision moved into scripts/notify_decision.py with 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: ca162b2 the sweep read created_at (pinned to attempt 1, so a failing re-run was invisible) and reported every workflow on the branch including itself; 4438eb7 and 6e0cd07 are the two Copilot findings; 7c525ca corrects the docs the above invalidated.

Verified: 134 tests, actionlint clean, workflow_graph.py clean here and on all seven consumers, and every one of the six commits independently green so a bisect never lands on a broken tree.

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
@lucas-koontz
lucas-koontz requested a review from a team as a code owner August 14, 2026 13:16
@lucas-koontz lucas-koontz self-assigned this Aug 14, 2026
@lucas-koontz
lucas-koontz requested a lite review from Copilot August 14, 2026 13:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-status composite 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) plus scripts/branch_health.py (red-branch sweep) with unit tests.
  • Refines scripts/workflow_graph.py event-keying and collision reporting to account for PR types and 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.

Comment thread scripts/freeze_state.py Outdated
Comment thread tests/test_workflow_graph.py Outdated
…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
lucas-koontz added a commit to mindsdb/cowork that referenced this pull request Aug 14, 2026
…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.
lucas-koontz added a commit to mindsdb/cowork-server that referenced this pull request Aug 14, 2026
…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.
lucas-koontz added a commit to mindsdb/anton that referenced this pull request Aug 14, 2026
…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.
@lucas-koontz

Copy link
Copy Markdown
Contributor Author

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.

  • scripts/freeze_state.py unguarded json.loads4438eb7. A non-JSON body now raises LookupError_ and prints the "provisioning has drifted" annotation instead of a traceback.
  • tests/test_workflow_graph.py name/docstring mismatch → 6e0cd07. It pinned nothing, because its input declared no branches: at all.

The two blockers I found in my own diff are in the reviewer notes above, and 569b6e4 is the one to read first: the recovery half of this ticket could not fire at all.

@lucas-koontz
lucas-koontz merged commit 4339ae6 into main Aug 14, 2026
5 checks passed
@lucas-koontz
lucas-koontz deleted the fix/eng-1549-freeze-scoped-alerting branch August 14, 2026 15:30
lucas-koontz added a commit to mindsdb/cowork that referenced this pull request Aug 14, 2026
…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.
lucas-koontz added a commit to mindsdb/cowork-server that referenced this pull request Aug 14, 2026
…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.
lucas-koontz added a commit to mindsdb/anton that referenced this pull request Aug 14, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants