Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .devops/templates/release-forced-warning.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Flags a release that published to npm but deliberately skipped the git push.
Comment thread
Hotell marked this conversation as resolved.
#
# Only fires when the git push preflight failed and the release was forced. In that case packages
# are live on npm while the repo still has the old versions, so someone must run the recovery.
#
# Include immediately AFTER the beachball publish step.

parameters:
- name: dryRun
type: boolean
default: false

steps:
- ${{ if eq(parameters.dryRun, false) }}:
- script: |
echo "##vso[task.logissue type=warning]Packages were published to npm but NOT pushed to git."
echo "##vso[task.logissue type=warning]Run the 'release-recovery' skill against this pipeline run to resync the repo."
echo ""
echo "The repository is now out of sync with npm."
echo ""
echo "To recover, from a clean fluentui checkout run:"
echo " /release-recovery $(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)"
echo ""
echo "Then fix the underlying cause - usually rotating the GitHub PAT in the"
echo "'Github and NPM secrets' variable group - or the next release will fail the same way."
echo ""

# Green-with-warning: the npm release genuinely succeeded, so a red run would be
# misleading and would train people to ignore failed release runs. But plain green would
# let the outstanding manual recovery slip by unnoticed.
echo "##vso[task.complete result=SucceededWithIssues;]Published to npm; git push skipped - recovery required"
displayName: 'Flag forced release for recovery'
condition: and(succeeded(), ne(variables['gitPushAvailable'], 'true'))
90 changes: 90 additions & 0 deletions .devops/templates/release-git-preflight.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Guards a release against publishing to npm with a git token that cannot push.
#
# Background: `beachball publish` publishes to npm BEFORE it commits/tags/pushes. npm publishes are
# irreversible, so an invalid git token leaves npm and the repo permanently out of sync (this
# happened on 2026-06-30 when an enterprise policy started rejecting classic PATs with a lifetime
# > 8 days: all 18 v8 packages published, then every push retry failed with 403).
#
# Usage: include this template BEFORE the beachball publish step, and add $(beachballPushArgs) to
# the publish command.
#
# - template: .devops/templates/release-git-preflight.yml@self
# parameters:
# branch: master
# dryRun: ${{ parameters.dryRun }}
# forceReleaseWithoutGitPush: ${{ parameters.forceReleaseWithoutGitPush }}
#
# - script: |
# yarn beachball publish $(beachballPushArgs) --config ... --message '...'
# env:
# GITHUB_PAT: $(githubPAT)
# NPM_TOKEN: $(npmToken)

parameters:
# Branch the release pushes bumps/changelogs/tags to.
- name: branch
type: string
default: master

# Git remote the release pushes to.
- name: remote
type: string
default: origin

# Emergency escape hatch. When true, a failed preflight no longer blocks the release: packages are
# still published to npm, but with `--no-push`, and a recovery bundle is produced instead.
# This is a permission to proceed, NOT a mandate to skip - a healthy token still does a full release.
- name: forceReleaseWithoutGitPush
type: boolean
default: false

# Dry runs never publish or push, so the preflight is skipped.
- name: dryRun
type: boolean
default: false

steps:
# Always define the variable so `$(beachballPushArgs)` is never left as an unexpanded macro,
# even on code paths where the preflight itself is skipped.
- script: |
echo "##vso[task.setvariable variable=beachballPushArgs;]"
displayName: 'Preflight: initialize push mode'

- ${{ if eq(parameters.dryRun, false) }}:
- script: |
set -euo pipefail

# ADO renders boolean parameters as "True"/"False"; normalize before comparing.
force=$(echo "$FORCE_RELEASE_WITHOUT_GIT_PUSH" | tr '[:upper:]' '[:lower:]')

allowFailure=""
if [ "$force" = "true" ]; then
allowFailure="--allow-failure"
echo "Force mode enabled: a failed preflight will not block the release."
fi

node -r ./scripts/ts-node/src/register ./scripts/executors/src/check-git-push-access.ts \
--remote "${{ parameters.remote }}" \
--branch "${{ parameters.branch }}" \
$allowFailure
env:
GITHUB_PAT: $(githubPAT)
FORCE_RELEASE_WITHOUT_GIT_PUSH: ${{ parameters.forceReleaseWithoutGitPush }}
displayName: 'Preflight: verify git push access'

# Translate the preflight result into the beachball flags used by the publish step.
#
# `--no-push` makes beachball publish to npm but skip commit/tag/push. Note it also skips the
# `precommit` hook and tagging, which is why force mode reproduces those separately when
# building the recovery bundle.
- script: |
set -euo pipefail

if [ "$(gitPushAvailable)" = "true" ]; then
echo "Git push available - performing a normal release."
echo "##vso[task.setvariable variable=beachballPushArgs;]"
else
echo "Git push NOT available - publishing to npm only (--no-push)."
echo "##vso[task.setvariable variable=beachballPushArgs;]--no-push"
fi
displayName: 'Preflight: select beachball push mode'
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,12 @@ state.root.className = mergeClasses(

## Workflows

| Topic | Location |
| ------------------------------------ | ---------------------------------------------------------------- |
| PR checklist, change files, commands | [docs/workflows/contributing.md](docs/workflows/contributing.md) |
| Testing guide (unit, VRT, SSR, E2E) | [docs/workflows/testing.md](docs/workflows/testing.md) |
| Team routing and label taxonomy | [docs/team-routing.md](docs/team-routing.md) |
| Topic | Location |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------- |
| PR checklist, change files, commands | [docs/workflows/contributing.md](docs/workflows/contributing.md) |
| Testing guide (unit, VRT, SSR, E2E) | [docs/workflows/testing.md](docs/workflows/testing.md) |
| Release git push preflight / forced release recovery | [docs/workflows/release-git-preflight.md](docs/workflows/release-git-preflight.md) |
| Team routing and label taxonomy | [docs/team-routing.md](docs/team-routing.md) |

## Quality Tracking

Expand Down
47 changes: 45 additions & 2 deletions azure-pipelines.release-vnext.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ parameters:
type: boolean
default: false

# Escape hatch for when the GitHub PAT is broken and a release cannot wait for it to be rotated.
# Publishes to npm but skips the git push. The run finishes as partiallySucceeded, and the repo is
# resynced afterwards by running the "release-recovery" skill against the run.
- name: forceReleaseWithoutGitPush
displayName: 'Force release even if git PAT is invalid (npm only, manual repo update required)'
type: boolean
default: false

variables:
- group: 'Github and NPM secrets'
- template: .devops/templates/variables.yml
Expand Down Expand Up @@ -77,6 +85,27 @@ extends:
filePath: yarn-ci.sh
displayName: yarn

# Fail fast: catch a broken GitHub PAT now rather than after a long build/test cycle.
# This is purely an optimization - the authoritative check runs again immediately
# before publish, so a token that expires mid-build is still caught.
- script: |
set -euo pipefail

force=$(echo "$FORCE_RELEASE_WITHOUT_GIT_PUSH" | tr '[:upper:]' '[:lower:]')

allowFailure=""
if [ "$force" = "true" ]; then
allowFailure="--allow-failure"
fi

node -r ./scripts/ts-node/src/register ./scripts/executors/src/check-git-push-access.ts \
--remote origin --branch master $allowFailure
condition: and(succeeded(), not(${{ parameters.dryRun }}))
env:
GITHUB_PAT: $(githubPAT)
FORCE_RELEASE_WITHOUT_GIT_PUSH: ${{ parameters.forceReleaseWithoutGitPush }}
displayName: 'Preflight (early): verify git push access'

- script: |
FLUENT_PROD_BUILD=true yarn nx run-many -t build -p tag:vNext --exclude 'tag:tools,tag:type:stories,apps/**' --nxBail
displayName: build
Expand All @@ -94,15 +123,29 @@ extends:
displayName: 'Deprecate preview packages'
condition: not(${{ parameters.dryRun }})

- template: .devops/templates/release-git-preflight.yml@self
parameters:
branch: master
dryRun: ${{ parameters.dryRun }}
forceReleaseWithoutGitPush: ${{ parameters.forceReleaseWithoutGitPush }}

- script: |
yarn beachball publish --config scripts/beachball/src/release-vNext.config.js --message 'release: applying package updates - react-components'
git reset --hard origin/master
yarn beachball publish $(beachballPushArgs) --config scripts/beachball/src/release-vNext.config.js --message 'release: applying package updates - react-components'
env:
GITHUB_PAT: $(githubPAT)
NPM_TOKEN: $(npmToken)
displayName: Publish changes and bump versions
condition: not(${{ parameters.dryRun }})

- template: .devops/templates/release-forced-warning.yml@self
parameters:
dryRun: ${{ parameters.dryRun }}

- script: |
git reset --hard origin/master
displayName: Reset workspace after publish
condition: not(${{ parameters.dryRun }})

- script: |
node -r ./scripts/ts-node/src/register scripts/executors/src/tag-react-components.ts --token $(npmToken)
displayName: Tag prelease packages with prerelease tag
Expand Down
47 changes: 45 additions & 2 deletions azure-pipelines.release.headless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ parameters:
type: boolean
default: false

# Escape hatch for when the GitHub PAT is broken and a release cannot wait for it to be rotated.
# Publishes to npm but skips the git push. The run finishes as partiallySucceeded, and the repo is
# resynced afterwards by running the "release-recovery" skill against the run.
- name: forceReleaseWithoutGitPush
displayName: 'Force release even if git PAT is invalid (npm only, manual repo update required)'
type: boolean
default: false

variables:
- group: 'Github and NPM secrets'
- template: .devops/templates/variables.yml
Expand Down Expand Up @@ -66,6 +74,27 @@ extends:
filePath: yarn-ci.sh
displayName: yarn

# Fail fast: catch a broken GitHub PAT now rather than after a long build/test cycle.
# This is purely an optimization - the authoritative check runs again immediately
# before publish, so a token that expires mid-build is still caught.
- script: |
set -euo pipefail

force=$(echo "$FORCE_RELEASE_WITHOUT_GIT_PUSH" | tr '[:upper:]' '[:lower:]')

allowFailure=""
if [ "$force" = "true" ]; then
allowFailure="--allow-failure"
fi

node -r ./scripts/ts-node/src/register ./scripts/executors/src/check-git-push-access.ts \
--remote origin --branch master $allowFailure
condition: and(succeeded(), not(${{ parameters.dryRun }}))
env:
GITHUB_PAT: $(githubPAT)
FORCE_RELEASE_WITHOUT_GIT_PUSH: ${{ parameters.forceReleaseWithoutGitPush }}
displayName: 'Preflight (early): verify git push access'

- script: |
echo "Following packages will be published (if they contain changes):"
yarn nx show projects -p 'tag:react-headless,!tag:npm:private' --exclude 'apps/**'
Expand All @@ -83,15 +112,29 @@ extends:
FLUENT_PROD_BUILD=true yarn nx run-many -t lint -p 'tag:react-headless,!tag:npm:private,!tag:type:stories' --exclude 'apps/**' --nxBail
displayName: lint

- template: .devops/templates/release-git-preflight.yml@self
parameters:
branch: master
dryRun: ${{ parameters.dryRun }}
forceReleaseWithoutGitPush: ${{ parameters.forceReleaseWithoutGitPush }}

- script: |
yarn beachball publish --config scripts/beachball/src/release-headless.config.js --message 'release: applying package updates - react-headless'
git reset --hard origin/master
yarn beachball publish $(beachballPushArgs) --config scripts/beachball/src/release-headless.config.js --message 'release: applying package updates - react-headless'
env:
GITHUB_PAT: $(githubPAT)
NPM_TOKEN: $(npmToken)
displayName: Publish changes and bump versions
condition: not(${{ parameters.dryRun }})

- template: .devops/templates/release-forced-warning.yml@self
parameters:
dryRun: ${{ parameters.dryRun }}

- script: |
git reset --hard origin/master
displayName: Reset workspace after publish
condition: not(${{ parameters.dryRun }})

- template: .devops/templates/cleanup.yml@self
parameters:
checkForModifiedFiles: false
47 changes: 45 additions & 2 deletions azure-pipelines.release.tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ parameters:
type: boolean
default: false

# Escape hatch for when the GitHub PAT is broken and a release cannot wait for it to be rotated.
# Publishes to npm but skips the git push. The run finishes as partiallySucceeded, and the repo is
# resynced afterwards by running the "release-recovery" skill against the run.
- name: forceReleaseWithoutGitPush
displayName: 'Force release even if git PAT is invalid (npm only, manual repo update required)'
type: boolean
default: false

variables:
- group: 'Github and NPM secrets'
- template: .devops/templates/variables.yml
Expand Down Expand Up @@ -66,6 +74,27 @@ extends:
filePath: yarn-ci.sh
displayName: yarn

# Fail fast: catch a broken GitHub PAT now rather than after a long build/test cycle.
# This is purely an optimization - the authoritative check runs again immediately
# before publish, so a token that expires mid-build is still caught.
- script: |
set -euo pipefail

force=$(echo "$FORCE_RELEASE_WITHOUT_GIT_PUSH" | tr '[:upper:]' '[:lower:]')

allowFailure=""
if [ "$force" = "true" ]; then
allowFailure="--allow-failure"
fi

node -r ./scripts/ts-node/src/register ./scripts/executors/src/check-git-push-access.ts \
--remote origin --branch master $allowFailure
condition: and(succeeded(), not(${{ parameters.dryRun }}))
env:
GITHUB_PAT: $(githubPAT)
FORCE_RELEASE_WITHOUT_GIT_PUSH: ${{ parameters.forceReleaseWithoutGitPush }}
displayName: 'Preflight (early): verify git push access'

- script: |
echo "Following packages will be published(if they contain changes):"
yarn nx show projects -p 'tag:tools,!tag:npm:private,!tag:v8' --exclude 'apps/**'
Expand All @@ -82,15 +111,29 @@ extends:
FLUENT_PROD_BUILD=true yarn nx run-many -t lint -p 'tag:tools,!tag:npm:private,!tag:v8' --exclude 'apps/**' --nxBail
displayName: lint

- template: .devops/templates/release-git-preflight.yml@self
parameters:
branch: master
dryRun: ${{ parameters.dryRun }}
forceReleaseWithoutGitPush: ${{ parameters.forceReleaseWithoutGitPush }}

- script: |
yarn beachball publish --config scripts/beachball/src/release-tools.config.js --message 'release: applying package updates - tools'
git reset --hard origin/master
yarn beachball publish $(beachballPushArgs) --config scripts/beachball/src/release-tools.config.js --message 'release: applying package updates - tools'
env:
GITHUB_PAT: $(githubPAT)
NPM_TOKEN: $(npmToken)
displayName: Publish changes and bump versions
condition: not(${{ parameters.dryRun }})

- template: .devops/templates/release-forced-warning.yml@self
parameters:
dryRun: ${{ parameters.dryRun }}

- script: |
git reset --hard origin/master
displayName: Reset workspace after publish
condition: not(${{ parameters.dryRun }})

- template: .devops/templates/cleanup.yml@self
parameters:
checkForModifiedFiles: false
Loading
Loading