From d203ef58fe432779ff987359055b129a61a47460 Mon Sep 17 00:00:00 2001 From: Maddison Das <272712104+MaddyMicrosoft@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:04:12 +1000 Subject: [PATCH 1/5] Add admin-gated Release workflow Reproduces the Azure Login Action release runbook as a manually dispatched workflow: checkout master, build, test, commit the built lib/ to a releases/vX.Y.Z branch, tag that commit, force-move the major tag (e.g. v3), and publish a GitHub Release. Admin-only is enforced two ways: - environment: release, which pauses the run until a required reviewer (admin) approves. This is the primary gate and must be configured with required reviewers in repo settings before the first real release. - an 'Ensure triggered by an admin' step that fails fast for non-admins. Actions are pinned to @v6 to match the rest of the repo. --- .github/workflows/release.yml | 162 ++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..2be9824b2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,162 @@ +name: Release + +# Manually triggered by a maintainer. Reproduces the Azure Login Action +# release runbook: build -> commit built lib to a release branch -> tag -> +# publish GitHub Release -> move the major tag (e.g. v3). +# +# ADMIN-ONLY: GitHub cannot restrict workflow_dispatch to admins at the trigger +# level (any user with write access can dispatch). Admin-only is enforced two ways: +# 1. environment: release -> requires an admin reviewer to approve the run +# (configure required reviewers under Settings > Environments > release). +# This is the real gate: the destructive steps cannot run without approval. +# 2. The "Ensure triggered by an admin" step below fails fast for non-admins. +on: + workflow_dispatch: + inputs: + version: + description: "Release version, e.g. v3.1.0" + required: true + ref: + description: "Branch/tag/SHA to release from. Use master for v3, releases/v2 for v2 maintenance." + required: false + default: master + latest: + description: "Mark as the latest release (only for the highest major, e.g. v3)" + type: boolean + default: true + +permissions: + contents: write # push branch/tag, force-move major tag, create the Release + +# Serialize releases: never let two release runs push tags/branches at once. +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + # Admin gate: this environment must have required reviewers (admins) configured + # in repo settings. The run pauses here until an admin approves. + environment: release + steps: + - name: Ensure triggered by an admin + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + PERM=$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq '.permission') + echo "Actor '$ACTOR' has repository permission: $PERM" + if [[ "$PERM" != "admin" ]]; then + echo "::error::Release must be triggered by a repository admin (actor has '$PERM')." + exit 1 + fi + + - name: Validate version input + env: + V: ${{ inputs.version }} + run: | + if [[ ! "$V" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must look like v3.1.0 (got '$V')" + exit 1 + fi + + - name: Validate release ref + env: + REF: ${{ inputs.ref }} + run: | + # Only allow releasing from vetted lines: master (current major) and + # the v2 maintenance branch. Prevents shipping arbitrary unreviewed + # branches straight to consumers via the moved major tag. + case "$REF" in + master|releases/v2|hotfix/*) echo "ref '$REF' allowed" ;; + *) echo "::error::ref '$REF' not permitted for release (allowed: master, releases/v2, hotfix/*)"; exit 1 ;; + esac + + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} # v3 releases from master; v2 from releases/v2 + fetch-depth: 0 + # fetch tags so the 'latest' guard can find the highest major + fetch-tags: true + + - name: Guard 'latest' against back-major releases + env: + V: ${{ inputs.version }} + LATEST: ${{ inputs.latest }} + run: | + # 'latest' controls only the "Latest" badge on the Releases page, of + # which there is one per repo. Refuse to move it onto an older major + # (e.g. a v2 hotfix must not steal the badge from v3). + MAJOR="${V%%.*}" # v2.3.2 -> v2 + # Highest major among existing tags AND this release, so a brand-new + # top major (e.g. first v4.0.0) is still allowed to be latest. + HIGHEST=$(printf '%s\n' $(git tag -l 'v*' | grep -E '^v[0-9]+$') "$MAJOR" | sort -V | tail -1) + echo "Release major: $MAJOR Highest major: $HIGHEST latest=$LATEST" + if [[ "$LATEST" == "true" && "$MAJOR" != "$HIGHEST" ]]; then + echo "::error::Refusing to mark $V as latest: $MAJOR is not the highest major ($HIGHEST). Re-run with latest=false for back-major releases." + exit 1 + fi + + - name: Ensure version is new + env: + V: ${{ inputs.version }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$V" >/dev/null 2>&1; then + echo "::error::tag $V already exists"; exit 1 + fi + if git ls-remote --exit-code --heads origin "refs/heads/releases/$V" >/dev/null 2>&1; then + echo "::error::branch releases/$V already exists"; exit 1 + fi + + - uses: actions/setup-node@v6 + with: + node-version: 24 # matches action.yml runs.using: node24 + cache: npm + + - name: Install, build, test + run: | + npm ci + npm run build + npm test + + - name: Create release branch with built lib + env: + V: ${{ inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "releases/$V" + git add -f lib + git commit -m "prepare release $V" + git push --set-upstream origin "releases/$V" + + - name: Create and push tag + env: + V: ${{ inputs.version }} + run: | + git tag -a -m "$V" "$V" + git push origin "$V" + + - name: Move major tag (e.g. v3) + env: + V: ${{ inputs.version }} + run: | + MAJOR="${V%%.*}" # v3.1.0 -> v3 + git tag -d "$MAJOR" || true + git tag -a "$MAJOR" -m "Update $MAJOR to $V" + git push origin "$MAJOR" -f + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + V: ${{ inputs.version }} + LATEST: ${{ inputs.latest }} + run: | + LATEST_FLAG="--latest=false" + [[ "$LATEST" == "true" ]] && LATEST_FLAG="--latest" + gh release create "$V" \ + --title "Azure Login Action $V" \ + --generate-notes \ + "$LATEST_FLAG" From 5ebd310631a953c4c499c756e9f66a9b2162e822 Mon Sep 17 00:00:00 2001 From: Maddison Das <272712104+MaddyMicrosoft@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:03:59 +0100 Subject: [PATCH 2/5] Add admin-gated Rollback workflow Undo a bad release by re-pointing the major tag (e.g. v3) back to a known-good, already-published version tag. This is the fast consumer-facing fix: anyone on azure/login@v3 recovers immediately. - Admin-gated the same two ways as release.yml: environment: release approval plus an actor-admin check. - Shares the 'release' concurrency group so a rollback can never race an in-flight release (both push the same major tag). - Verifies the target tag exists and carries both built entry points (lib/main/index.js and lib/cleanup/index.js) before moving the major tag, so it can never point consumers at unbuilt code. - Optionally resets the 'Latest' release badge; tolerates a missing Release object so a cosmetic badge failure never fails a successful tag rollback. - Does NOT delete the bad tag/branch/release (someone may have pinned the exact version); left as a deliberate manual step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rollback.yml | 132 +++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/rollback.yml diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml new file mode 100644 index 000000000..24de3050b --- /dev/null +++ b/.github/workflows/rollback.yml @@ -0,0 +1,132 @@ +name: Rollback + +# Manually triggered by a maintainer to undo a bad release. A release moves the +# major tag (e.g. v3) forward to the new version; consumers pinned to +# `azure/login@v3` immediately get it. If that release is broken, rollback +# re-points the major tag back to a known-good, already-published version tag +# (e.g. v3.0.1), which contains the built lib/. This is the fast consumer-facing +# fix. It does NOT delete the bad tag/branch/release by default - deletion is +# destructive (someone may have pinned the exact version) and is left to a +# deliberate manual step if ever needed. +# +# ADMIN-ONLY: enforced the same two ways as release.yml: +# 1. environment: release -> requires an admin reviewer to approve the run. +# 2. The "Ensure triggered by an admin" step fails fast for non-admins. +on: + workflow_dispatch: + inputs: + target_version: + description: "Known-good version to roll the major tag back to, e.g. v3.0.1 (must be an existing tag)" + required: true + set_latest: + description: "Also reset the GitHub 'Latest' release badge to target_version" + type: boolean + default: true + +permissions: + contents: write # force-move the major tag, update the Release + +# Share the 'release' concurrency group so a rollback can never race an +# in-flight release (both push the same major tag). +concurrency: + group: release + cancel-in-progress: false + +jobs: + rollback: + runs-on: ubuntu-latest + # Admin gate: this environment must have required reviewers (admins) + # configured in repo settings. The run pauses here until an admin approves. + environment: release + steps: + - name: Ensure triggered by an admin + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + PERM=$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq '.permission') + echo "Actor '$ACTOR' has repository permission: $PERM" + if [[ "$PERM" != "admin" ]]; then + echo "::error::Rollback must be triggered by a repository admin (actor has '$PERM')." + exit 1 + fi + + - name: Validate target_version input + env: + V: ${{ inputs.target_version }} + run: | + if [[ ! "$V" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::target_version must look like v3.0.1 (got '$V')" + exit 1 + fi + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Verify target tag exists and contains built lib + env: + V: ${{ inputs.target_version }} + run: | + # The target must be an existing published version tag... + if ! git rev-parse -q --verify "refs/tags/$V" >/dev/null; then + echo "::error::tag $V does not exist - can only roll back to an already-published version" + exit 1 + fi + # ...and it must carry the built entry points (main + post), or + # consumers would break worse than before (the major tag must always + # point at runnable code). action.yml uses lib/main/index.js and + # lib/cleanup/index.js. + git checkout --quiet "$V" + missing="" + [[ -f lib/main/index.js ]] || missing="$missing lib/main/index.js" + [[ -f lib/cleanup/index.js ]] || missing="$missing lib/cleanup/index.js" + if [[ -n "$missing" ]]; then + echo "::error::tag $V is missing built entry point(s):$missing - refusing to point the major tag at unbuilt code" + exit 1 + fi + + - name: Re-point major tag to target + env: + V: ${{ inputs.target_version }} + run: | + MAJOR="${V%%.*}" # v3.0.1 -> v3 + CURRENT=$(git rev-parse "refs/tags/$MAJOR^{commit}" 2>/dev/null || echo "none") + TARGET=$(git rev-parse "$V^{commit}") + echo "Rolling major $MAJOR: $CURRENT -> $TARGET ($V)" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -d "$MAJOR" || true + git tag -a "$MAJOR" -m "Rollback $MAJOR to $V" "$V" + git push origin "$MAJOR" -f + + - name: Reset 'Latest' release badge to target + if: ${{ inputs.set_latest }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + V: ${{ inputs.target_version }} + run: | + # Point the repo's single "Latest" badge back at the good version, so + # the Releases page and /releases/latest stop advertising the bad one. + # A tag can exist without a published Release, so tolerate a missing + # release object: the critical tag rollback already succeeded above, + # and the badge is cosmetic - warn rather than fail the whole rollback. + if gh release view "$V" --repo "$REPO" >/dev/null 2>&1; then + gh release edit "$V" --latest --repo "$REPO" + echo "Set '$V' as the Latest release." + else + echo "::warning::No GitHub Release exists for $V, so the 'Latest' badge was not changed. The major tag rollback still succeeded. Create/publish a Release for $V if you want the badge updated." + fi + + - name: Summary + env: + V: ${{ inputs.target_version }} + run: | + MAJOR="${V%%.*}" + echo "### Rollback complete" >> "$GITHUB_STEP_SUMMARY" + echo "- Major tag \`$MAJOR\` now points at \`$V\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Consumers using \`azure/login@$MAJOR\` now get \`$V\`" >> "$GITHUB_STEP_SUMMARY" + echo "- The bad tag/branch/release were NOT deleted (delete manually if required)." >> "$GITHUB_STEP_SUMMARY" From a3675724e2d77427ef7b43708a1ec97dd12e8c24 Mon Sep 17 00:00:00 2001 From: Maddison Das <272712104+MaddyMicrosoft@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:37:29 +1000 Subject: [PATCH 3/5] Build releases with the node version from action.yml --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2be9824b2..d04c749c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,9 +110,16 @@ jobs: echo "::error::branch releases/$V already exists"; exit 1 fi + - name: Determine runtime node from action.yml + id: node + shell: bash + run: | + ver=$(grep "using:" action.yml | grep -oE "node[0-9]+" | grep -oE "[0-9]+" | head -1) + echo "version=$ver" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v6 with: - node-version: 24 # matches action.yml runs.using: node24 + node-version: ${{ steps.node.outputs.version }} # matches action.yml runs.using cache: npm - name: Install, build, test From cdca0c550cb955967d20addafb10e3a149940eb4 Mon Sep 17 00:00:00 2001 From: Maddison Das <272712104+MaddyMicrosoft@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:13:43 +0100 Subject: [PATCH 4/5] Restrict release ref allowlist to master and hotfix/* branches The allowlist previously accepted releases/v2 for v2 maintenance, but releases/v2 is a stale early-v2 snapshot that does not contain the later v2 releases (v2.3.0, v2.3.1, ...). Releasing from it would ship old code. The latest v2 source lives in the newest v2 release tag. To cut a back-major release, branch a hotfix off that tag (git checkout -b hotfix/v2.3.2 v2.3.1), apply the fix, and release from the hotfix/* branch. Drop releases/v2 from the allowlist so it can no longer be selected by mistake. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d04c749c5..ab7e96b5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ on: description: "Release version, e.g. v3.1.0" required: true ref: - description: "Branch/tag/SHA to release from. Use master for v3, releases/v2 for v2 maintenance." + description: "Branch to release from. Use master for the current major (v3). For a back-major release (e.g. v2), branch a hotfix off the latest release tag (git checkout -b hotfix/v2.3.2 v2.3.1) and pass that hotfix/* branch." required: false default: master latest: @@ -70,13 +70,13 @@ jobs: # the v2 maintenance branch. Prevents shipping arbitrary unreviewed # branches straight to consumers via the moved major tag. case "$REF" in - master|releases/v2|hotfix/*) echo "ref '$REF' allowed" ;; - *) echo "::error::ref '$REF' not permitted for release (allowed: master, releases/v2, hotfix/*)"; exit 1 ;; + master|hotfix/*) echo "ref '$REF' allowed" ;; + *) echo "::error::ref '$REF' not permitted for release (allowed: master, or a hotfix/* branch based on the latest release tag for a back-major release)"; exit 1 ;; esac - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} # v3 releases from master; v2 from releases/v2 + ref: ${{ inputs.ref }} # master for the current major; a hotfix/* branch for a back-major release fetch-depth: 0 # fetch tags so the 'latest' guard can find the highest major fetch-tags: true From 26b86c76d72064da0fd4557d7fc4e3d0cbe77444 Mon Sep 17 00:00:00 2001 From: Maddison Das <272712104+MaddyMicrosoft@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:15:22 +0100 Subject: [PATCH 5/5] Drop explicit 'latest' handling; rely on GitHub auto-latest gh release create defaults to marking the highest-version release as "Latest", so a back-major release (e.g. a v2 hotfix) never steals the badge from the current major on its own. The explicit latest controls were therefore redundant, and rollback's badge edit conflicted with immutable releases. - release.yml: remove the latest input, the back-major latest guard, and the --latest flag on gh release create. - rollback.yml: remove the set_latest input and the gh release edit --latest badge step. Rollback now does only the major-tag move (matching the original runbook) plus the admin gate and built-artifact checks. The "Latest" badge is display-only and never affects what azure/login@vN resolves to, so this is behaviourally safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 32 ++++---------------------------- .github/workflows/rollback.yml | 23 ----------------------- 2 files changed, 4 insertions(+), 51 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab7e96b5d..510efe23d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,6 @@ on: description: "Branch to release from. Use master for the current major (v3). For a back-major release (e.g. v2), branch a hotfix off the latest release tag (git checkout -b hotfix/v2.3.2 v2.3.1) and pass that hotfix/* branch." required: false default: master - latest: - description: "Mark as the latest release (only for the highest major, e.g. v3)" - type: boolean - default: true permissions: contents: write # push branch/tag, force-move major tag, create the Release @@ -78,27 +74,8 @@ jobs: with: ref: ${{ inputs.ref }} # master for the current major; a hotfix/* branch for a back-major release fetch-depth: 0 - # fetch tags so the 'latest' guard can find the highest major fetch-tags: true - - name: Guard 'latest' against back-major releases - env: - V: ${{ inputs.version }} - LATEST: ${{ inputs.latest }} - run: | - # 'latest' controls only the "Latest" badge on the Releases page, of - # which there is one per repo. Refuse to move it onto an older major - # (e.g. a v2 hotfix must not steal the badge from v3). - MAJOR="${V%%.*}" # v2.3.2 -> v2 - # Highest major among existing tags AND this release, so a brand-new - # top major (e.g. first v4.0.0) is still allowed to be latest. - HIGHEST=$(printf '%s\n' $(git tag -l 'v*' | grep -E '^v[0-9]+$') "$MAJOR" | sort -V | tail -1) - echo "Release major: $MAJOR Highest major: $HIGHEST latest=$LATEST" - if [[ "$LATEST" == "true" && "$MAJOR" != "$HIGHEST" ]]; then - echo "::error::Refusing to mark $V as latest: $MAJOR is not the highest major ($HIGHEST). Re-run with latest=false for back-major releases." - exit 1 - fi - - name: Ensure version is new env: V: ${{ inputs.version }} @@ -159,11 +136,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} V: ${{ inputs.version }} - LATEST: ${{ inputs.latest }} run: | - LATEST_FLAG="--latest=false" - [[ "$LATEST" == "true" ]] && LATEST_FLAG="--latest" + # No --latest flag: GitHub automatically marks the highest-version + # release as "Latest", so a back-major release (e.g. a v2 hotfix) does + # not steal the badge from the current major. gh release create "$V" \ --title "Azure Login Action $V" \ - --generate-notes \ - "$LATEST_FLAG" + --generate-notes diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml index 24de3050b..74f127e99 100644 --- a/.github/workflows/rollback.yml +++ b/.github/workflows/rollback.yml @@ -18,10 +18,6 @@ on: target_version: description: "Known-good version to roll the major tag back to, e.g. v3.0.1 (must be an existing tag)" required: true - set_latest: - description: "Also reset the GitHub 'Latest' release badge to target_version" - type: boolean - default: true permissions: contents: write # force-move the major tag, update the Release @@ -102,25 +98,6 @@ jobs: git tag -a "$MAJOR" -m "Rollback $MAJOR to $V" "$V" git push origin "$MAJOR" -f - - name: Reset 'Latest' release badge to target - if: ${{ inputs.set_latest }} - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - V: ${{ inputs.target_version }} - run: | - # Point the repo's single "Latest" badge back at the good version, so - # the Releases page and /releases/latest stop advertising the bad one. - # A tag can exist without a published Release, so tolerate a missing - # release object: the critical tag rollback already succeeded above, - # and the badge is cosmetic - warn rather than fail the whole rollback. - if gh release view "$V" --repo "$REPO" >/dev/null 2>&1; then - gh release edit "$V" --latest --repo "$REPO" - echo "Set '$V' as the Latest release." - else - echo "::warning::No GitHub Release exists for $V, so the 'Latest' badge was not changed. The major tag rollback still succeeded. Create/publish a Release for $V if you want the badge updated." - fi - - name: Summary env: V: ${{ inputs.target_version }}