Skip to content

feat(release): fail release pipelines before npm publish if git push is unavailable - #36564

Draft
Martin Hochel (Hotell) wants to merge 3 commits into
microsoft:masterfrom
Hotell:feat/release-git-push-preflight
Draft

feat(release): fail release pipelines before npm publish if git push is unavailable#36564
Martin Hochel (Hotell) wants to merge 3 commits into
microsoft:masterfrom
Hotell:feat/release-git-push-preflight

Conversation

@Hotell

@Hotell Martin Hochel (Hotell) commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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:

  1. The token is usable — catches expiry, revocation, and the enterprise policy that rejects long-lived classic PATs. Surfaces the github-authentication-token-expiration header when present, so the log says when it expired.
  2. It can actually pushgit 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 forceReleaseWithoutGitPush pipeline parameter for when a release genuinely cannot wait for a token rotation. It publishes to npm, skips the push, and finishes as partiallySucceeded with 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.md as follow-up, out of scope here.

Notes for reviewers

  • Wired into all 5 push pipelines identically: an early fail-fast check right after yarn, then the gate immediately before publish.
  • The PAT is redacted from all output.
  • scripts/executors is private, so no change file is required.
  • All six token/force permutations were exercised locally; the preflight fails closed with no token (exit 1) and passes in force mode (exit 0).

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.

…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>
@github-actions

Copy link
Copy Markdown

📊 Bundle size report

✅ No changes found

@github-actions

Copy link
Copy Markdown

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>
Comment thread .devops/templates/release-forced-warning.yml

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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 publish through a conditional --no-push mode 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.

Comment on lines +173 to +177
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}`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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-run is 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.

Comment on lines +67 to +81
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.`,
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@Hotell

Copy link
Copy Markdown
Contributor Author

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:

#36564 (this) #36566
npm token validated gap
GitHub token validated
Push permission proven git push --dry-run ⚠️ read-only API check
Pipelines covered 5 1 (web-components)
Force-release escape hatch
Unit tests ✅ (added in ee36a91)

Their repo check can't confirm push access, because microsoft/fluentui is public — GET /repos/microsoft/fluentui returns 200 with no credentials at all, so it passes for any valid token including a read-only one. The API returns a permissions.push field that would fix it in one line.

Conversely this PR doesn't validate the npm token at all, so a bad NPM_TOKEN still burns a full build before failing at publish.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants