Make the branch-freeze required status self-healing - #14548
Conversation
The `branch-freeze` commit status is a required check, but nothing posted it for pull requests opened by our own workflows. `branch-freeze-pr-status.yml` reacts to `pull_request_target`, and GitHub suppresses workflow runs for pull request events created with the built-in `GITHUB_TOKEN`, so those pull requests waited forever on a status nobody wrote (for example #14542). Add `workflow_run` triggers for the six workflows that open pull requests from Actions, plus a twice-daily scheduled sweep as a safety net. Fix three related correctness problems this exposed: * All status writers now share one `branch-freeze-write` concurrency group with `queue: max`. The previous per-base-branch group put nearly every pull request in one queue, and `cancel-in-progress: false` only protects the running job: a newer run evicted an already-pending one, silently dropping a required status. * `set-pr-status.ps1` resolves the pull request's current head and base from GitHub after acquiring that lock instead of trusting the event payload. Queued runs have no guaranteed start order, so a stale retarget event could otherwise report a frozen branch as open. * `Set-GitHubCommitStatus` skips writes that would not change the status, keeping long-lived pull requests away from the 1000-statuses-per-context API limit. The read is best-effort: if it fails, the status is still posted. Skipped `workflow_run` conclusions are ignored so that `Backport PR to branch`, which is requested on every issue comment, does not trigger repository-wide sweeps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c42f84f-0e98-4d6b-8df9-4c75335ed330
There was a problem hiding this comment.
Pull request overview
This PR hardens the branch-freeze required commit status so it can’t get “stuck missing” (and therefore block merging) when PRs are opened by repository automation that triggers GitHub’s workflow-recursion suppression. It adds additional refresh entry points, makes the concurrency strategy truly queue-based, and ensures status writes are based on the PR’s current head/base after acquiring the global lock, while also avoiding redundant status writes to reduce the risk of hitting GitHub’s per-context status limits.
Changes:
- Added
workflow_run+ twice-dailyscheduletriggers to proactively refreshbranch-freezestatuses for PRs created by automation workflows, while ignoringworkflow_runevents withconclusion: skipped. - Reworked concurrency to a single shared
branch-freeze-writegroup withqueue: maxso pending runs are not silently replaced/dropped. - Enhanced the status writer scripts/modules to (a) resolve PR head/base under the lock via
gh pr view, and (b) skip no-op status updates by best-effort reading the current status first; expanded the local contract/unit tests accordingly.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| .github/workflows/branch-freeze-tests.yml | Ensures contract tests run when any workflow name/path that workflow_run depends on changes. |
| .github/workflows/branch-freeze-refresh.yml | Adds workflow_run + schedule triggers; switches to shared queued concurrency group; skips skipped conclusions. |
| .github/workflows/branch-freeze-pr-status.yml | Switches to shared queued concurrency group; passes only PR number so head/base are resolved after lock acquisition. |
| .github/workflows/branch-freeze-command.yml | Updates documentation comment to reflect shared queued lock semantics for all status writers. |
| .github/branch-freeze/workflows/set-pr-status.ps1 | Adds -PullRequestNumber parameter set and resolves current PR head/base via GitHub API after the lock is held. |
| .github/branch-freeze/tests/run-tests.ps1 | Adds workflow-contract tests + new behavioral tests for stale event data, read-failure fallback, creator handling, and no-op updates. |
| .github/branch-freeze/tests/mock-gh.ps1 | Extends the gh mock to support pr view and commit-status list reads (including simulated read failures). |
| .github/branch-freeze/components/github/GitHubStatusChecksClient.psm1 | Adds best-effort “read current status then skip no-op write” logic; treats non-actions creators as mismatches. |
| .github/branch-freeze/components/github/GitHubPullRequestsClient.psm1 | Adds Get-GitHubPullRequest to fetch current headRefOid / baseRefName for accurate status stamping. |
There was a problem hiding this comment.
Review Summary
This PR correctly addresses a real reliability gap: automation-created PRs (via GITHUB_TOKEN) never received a branch-freeze commit status because GitHub suppresses pull_request_target runs for those. The three-part fix is well-designed:
- Status deduplication (
Get-GitHubCommitStatus+Test-GitHubCommitStatusMatches) avoids hitting the 1,000-statuses-per-context API budget on no-op writes. - Stale-event-data fix: The
branch-freeze-pr-status.ymlworkflow now passes only the PR number into the concurrency lock, then resolves the live head SHA and base branch inside the lock — eliminating the race where a queued event payload could carry stale branch data from before a retarget. workflow_runtrigger: The refresh workflow now fires after every automation workflow that can create PRs, plus a twice-daily cron safety net.
Strengths
- The test suite grows proportionally with the new behavior, including the explicit "workflow contracts" tests that fail if the YAML is later edited in a way that breaks the invariants.
- Error handling in
Set-GitHubCommitStatusis explicitly fail-open: a read failure emits a warning but always proceeds to write, so a stale or missing read can never leave a required status unanswered. - The
github-actions[bot]creator check ensures statuses posted by other integrations are replaced rather than silently accepted.
Minor observations (no blocking issues)
See inline comments for details:
per_page=100pagination assumption inGet-GitHubCommitStatusworkflow_runtriggers onfailure/cancelledruns (intentionally broad but worth documenting)- Global
branch-freeze-writeconcurrency group serializes all PR updates (intentional trade-off, mitigated by deduplication)
Overall this is a solid, well-tested reliability improvement. The design decisions are justified and documented.
Warning
Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
awmgmcpg
To allow these domains, add them to the
network.allowedlist in your workflow frontmatter:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
Generated by Expert Code Review (on open) for #14548 · sonnet46 · 164.5 AIC · ⌖ 5.35 AIC · ⊞ 4.9K
Two gaps in the previous commit, both instances of a rule it already established elsewhere: a pending run must not be silently evicted, and a read must never be able to suppress the required status write. branch-freeze-command.yml kept an unqueued concurrency group. That group is held for the whole run, including the refresh job, which now waits on the repository-wide branch-freeze-write queue rather than a per-branch one -- so the window in which a pending command can be evicted got longer. An evicted /freeze or /unfreeze never toggles the branch and its author is never told, so give that group queue: max too. set-pr-status.ps1 resolved the head and base through gh pr view under $ErrorActionPreference = 'Stop', so a failed read aborted the job and posted nothing -- the exact outcome Set-GitHubCommitStatus goes out of its way to avoid for its own status read, and something the event payload path could not do before. The live read stays authoritative and is still tried first; a failure or an incomplete response now warns and falls back to the payload head and base supplied by the workflow. Stale data is corrected by the next event or sweep, whereas a missing status leaves the pull request unmergeable. With no fallback available the script still throws rather than stamping the wrong commit. The workflow contract test that asserted the event base ref was absent is replaced by a tighter one: it may appear exactly once, and only on the FALLBACK_BASE_REF line, so it cannot be trusted as the primary source again. Tests: 118 passed, 0 failed (was 112). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ddcc43f9-f40f-441b-8b7d-e216868aca6d
Context
The
branch-freezecommit status is a required check, but nothing posts it for pull requests opened by our own workflows.branch-freeze-pr-status.ymlreacts topull_request_target, and GitHub suppresses workflow runs for pull request events created with the built-inGITHUB_TOKEN, so those pull requests wait forever on a status nobody writes and stay unmergeable — for example #14542, which has 10 passing checks, zero commit statuses, andmergeStateStatus: BLOCKED.Changes Made
workflow_runtrigger tobranch-freeze-refresh.ymlfor the six workflows that can open a pull request from Actions (Backport PR to branch,Dreaming (learning atoms curation),Flaky Test Auto-Fixer,Flaky Test Triage,Inter-branch merge workflow,Sync Microsoft.Build version in analyzer template with Version.props).schedule(23 5,17 * * *) as a safety net for anything the other entry points miss.workflow_runevents whose conclusion isskipped, soBackport PR to branch— which is requested on every issue comment — does not trigger repository-wide sweeps.branch-freeze-writegroup plusqueue: maxin bothbranch-freeze-pr-status.ymlandbranch-freeze-refresh.yml. The old group put nearly every pull request in one queue, andcancel-in-progress: falseonly protects the running job — a newer run evicted an already-pending one, silently dropping a required status.set-pr-status.ps1to accept-PullRequestNumberand resolve the pull request's current head and base via the newGet-GitHubPullRequestafter acquiring the concurrency lock, instead of trusting the event payload. Queued runs have no guaranteed start order, so a stale retarget event could otherwise report a frozen branch as open.Get-GitHubCommitStatusandTest-GitHubCommitStatusMatchestoGitHubStatusChecksClient.psm1soSet-GitHubCommitStatusskips writes that would not change the status, keeping long-lived pull requests away from the 1000-statuses-per-context API limit.::warning::and still posts the status, since a redundant status is harmless but a missing one blocks merging.creatoris missing or is notgithub-actions[bot]as a mismatch, so a status from another integration is replaced rather than trusted — the ruleset is only satisfied by the GitHub Actions integration.branch-freeze-tests.ymlso the workflow-name contract tests run on the pull requests that can break them.Testing
.github/branch-freeze/tests/run-tests.ps1— 112 passed, 0 failed (was 73 before this change).run-tests.ps1:current frozen base wins over stale event data— asserts a stale retarget event still reports the current frozen base.unreadable current status still posts the required status— asserts a failed status read does not suppress the write.status of unknown origin is replaced rather than trusted— asserts a status with nocreatoris reposted.GitHub Actions reposts a status created by another integration— asserts a foreigncreatoris not treated as satisfying the check.matching open/frozen branch status is not reposted— asserts identical statuses are skipped.single-PR workflow does not trust an event-captured base branch— asserts the workflow no longer passesgithub.event.pull_request.base.ref.<workflow> declares the expected workflow name/these tests run when <workflow> changes— pins the sixworkflow_runnames and their test-trigger paths.js-yaml, and all.ps1/.psm1files parse with[System.Management.Automation.Language.Parser](the same check CI runs).Get-GitHubPullRequestagainst the live API (dotnet/msbuild#14542) and confirmed/commits/{sha}/statusesreturnscreatorwhile the combined/commits/{sha}/statusendpoint does not.Notes
workflow_dispatchwith a blankbase_ref.queue: maxrequires the concurrency queue support announced 2026-05-07; it cannot be combined withcancel-in-progress: true, which this change does not use.gh pr listsnapshot taken at job start rather than re-resolving per pull request. A retarget mid-sweep is corrected by the retarget's ownpull_request_targetrun, which queues on the same lock and executes afterwards.