feat(release): fail release pipelines before npm publish if git push is unavailable - #36564
feat(release): fail release pipelines before npm publish if git push is unavailable#36564Martin Hochel (Hotell) wants to merge 3 commits into
Conversation
…is unavailable beachball publishes to npm first and only then commits, tags and pushes. If the git PAT is expired, revoked or blocked by policy, the npm publish has already happened by the time the push fails - which is irreversible. That is what happened to the v8 release on June 30: all 18 packages were published, every push retry got a 403, and the repo had to be resynced by hand. Adds a preflight that verifies push access *before* publish, so a bad token fails the run with no side effects. It checks two things, because neither is sufficient alone: the token is usable (catches expiry, revocation and the enterprise policy that rejects long-lived classic PATs, surfacing the expiration header when present), and it can actually push (catches a valid token without write access). A non-fast-forward rejection is explicitly not treated as failure - it means auth succeeded against a stale checkout, and beachball fetches and merges in its own retry loop. Also adds an opt-in forceReleaseWithoutGitPush parameter for when a release cannot wait for a token rotation: it publishes to npm, skips the push, and finishes as partiallySucceeded with the recovery command in the log, rather than failing after a half-completed release. This shrinks the window rather than removing it - publish still precedes push. The durable fix is moving off long-lived classic PATs; see the doc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
📊 Bundle size report✅ No changes found |
|
Pull request demo site: URL |
check-git-push-access imports fluentRepoDetails from @fluentui/scripts-github, but the package was not declared in scripts/executors, which failed import/no-extraneous-dependencies in scripts-executors:lint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a git-push preflight gate to Fluent UI’s Azure DevOps release pipelines to prevent irreversible npm publishes when the configured GitHub PAT can’t push commits/tags back to the repo, with an explicit “force” escape hatch that publishes npm-only and flags the run for manual recovery.
Changes:
- Adds a new executor script to validate GitHub token usability (API) and actual push permission (
git push --dry-run), surfacing actionable diagnostics and setting an ADO variable for later steps. - Wires a shared preflight template into all release pipelines and routes
beachball publishthrough a conditional--no-pushmode when push isn’t available (force mode only). - Documents the behavior and recovery workflow, and links it from
AGENTS.md.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds @fluentui/scripts-github dependency for the executors workspace package. |
| scripts/executors/src/check-git-push-access.ts | New preflight CLI: validates token via GitHub API + validates push via git push --dry-run; sets gitPushAvailable. |
| scripts/executors/package.json | Adds @fluentui/scripts-github dependency needed by the new preflight script. |
| docs/workflows/release-git-preflight.md | New documentation describing the preflight, force mode behavior, and recovery steps. |
| azure-pipelines.release.yml | Adds force parameter, early preflight, pre-publish preflight template, and forced-release warning/reset steps (v8 pipeline). |
| azure-pipelines.release.web-components.yml | Same integration for web-components release pipeline. |
| azure-pipelines.release.tools.yml | Same integration for tools release pipeline. |
| azure-pipelines.release.headless.yml | Same integration for headless release pipeline. |
| azure-pipelines.release-vnext.yml | Same integration for vNext release pipeline. |
| AGENTS.md | Adds workflow doc link for release preflight/recovery. |
| .devops/templates/release-git-preflight.yml | New reusable template that runs the authoritative preflight and sets beachballPushArgs for publish. |
| .devops/templates/release-forced-warning.yml | New template to flag forced npm-only releases and mark the run as SucceededWithIssues with recovery instructions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| async function runPreflight(options: PreflightOptions): Promise<void> { | ||
| const token = process.env.GITHUB_PAT; | ||
|
|
||
| console.log('Validating git push access before publishing...\n'); | ||
| console.log(` repo: ${fluentRepoDetails.owner}/${fluentRepoDetails.repo}`); |
There was a problem hiding this comment.
Agreed — and the daysLeft bug in the sibling comment proves the point, since a test would have caught it immediately.
Added check-git-push-access.spec.ts in ee36a91 — 19 tests mocking Octokit and spawnSync, covering exactly the branches called out:
- token valid / expired / expiring within 24h (regression) / expiring soon (warn, don't fail)
- 401 vs 403 vs API outage — the outage case asserts we do not fail the release, since
git push --dry-runis authoritative - push rejected vs stale-ref rejection (all three phrasings) — the carve-out that stops a moved branch from blocking a healthy release
- git not executable
- neither failure path ever echoes the token into the log
The CLI entry is now behind require.main === module so importing the module doesn't parse argv or call process.exit; the node -r ts-node ... invocation used by the pipelines is unchanged and re-verified.
| const expiresAt = new Date(String(expiration).replace(' UTC', 'Z').replace(' ', 'T')); | ||
| const daysLeft = Math.floor((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); | ||
|
|
||
| console.log(` token expires: ${expiration} (~${daysLeft} day(s) from now)`); | ||
|
|
||
| if (daysLeft <= 0) { | ||
| return { summary: `The GitHub token expired on ${expiration}.` }; | ||
| } | ||
| // Not a failure on its own - the push check below is authoritative - but worth surfacing early | ||
| // so the token gets rotated before it breaks a release. | ||
| if (daysLeft <= 3) { | ||
| console.warn( | ||
| ` ##vso[task.logissue type=warning]GitHub token expires in ~${daysLeft} day(s) - rotate it soon.`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Real bug, and a nasty one — it fails a release that would have succeeded, which is the exact false-positive class this gate is supposed to avoid. A token with 23h of life left floored to daysLeft === 0 and hit the "already expired" branch.
Fixed in ee36a91: expiry is decided on the raw millisecond delta, and the rounded value is only used for the human-readable log line. The soon-to-expire warning threshold moved to raw ms too.
Covered by two regression tests. I verified they actually catch it by reverting to Math.floor — both fail, and pass again with the fix.
…pired daysLeft floored the delta to whole days, so a token with 23h of life left reported 0 and hit the 'already expired' branch - failing a release that would have succeeded. Expiry is now decided on the raw millisecond delta; the rounded value is only used for the human-readable log line. Adds a test suite for the failure classification, which is the part of this change most likely to regress and the hardest to exercise by hand: token valid/expired/expiring, 401 vs 403 vs API outage, push rejected vs stale-ref rejection, git not executable, and that neither path ever echoes the token. Also guards the CLI entry with require.main so importing the module for tests doesn't parse argv or call process.exit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Heads up: Jane Chu (@janechu) opened #36566, which independently tackles the same failure from the other side — it validates npm + GitHub credentials for the web-components pipeline. The two are complementary rather than competing, and there's a real gap in this PR that theirs closes:
Their repo check can't confirm push access, because Conversely this PR doesn't validate the npm token at all, so a bad I've proposed on #36566 that their npm validation folds into the shared preflight template here, so both credentials are checked in one step across all five pipelines instead of just web-components. Flagging so this doesn't get merged as if it were the whole story. |
Previous Behavior
beachball publishes to npm first, and only then commits, tags and pushes. If the git PAT is expired, revoked or blocked by policy, the npm publish has already happened by the time the push fails — and npm publishes are irreversible.
That is what happened to the v8 release on 2026-06-30: all 18 packages published, every push retry got a
403(enterprise policy rejecting a classic PAT with a >8-day lifetime), and the repo had to be resynced by hand. The pipeline had no way to know the token was bad until after the irreversible step.New Behavior
Adds a preflight that verifies push access before publish, so a bad token fails the run with no side effects.
It checks two things, because neither is sufficient alone:
github-authentication-token-expirationheader when present, so the log says when it expired.git push --dry-run, which catches a valid token that lacks write access.A non-fast-forward rejection is explicitly not treated as failure: it means auth succeeded against a stale checkout, and beachball fetches and merges in its own retry loop. Treating it as failure would block healthy releases.
Force mode
Adds an opt-in
forceReleaseWithoutGitPushpipeline parameter for when a release genuinely cannot wait for a token rotation. It publishes to npm, skips the push, and finishes aspartiallySucceededwith the recovery command in the log — a deliberate, visible, recoverable state rather than a surprise half-completed release.Known limitation (documented)
This shrinks the bad-token window rather than removing it — publish still precedes push, so a token could in principle die in between. The durable fix is moving off long-lived classic PATs to a GitHub App or fine-grained token. Called out in
docs/workflows/release-git-preflight.mdas follow-up, out of scope here.Notes for reviewers
yarn, then the gate immediately before publish.scripts/executorsis private, so no change file is required.Independent of the other two PRs in this series (#36563, #36565) — mergeable in any order.
Related Issue(s)
N/A — follow-up to the 2026-06-30 v8 release incident.