diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..510efe23d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +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 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 + +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|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 }} # master for the current major; a hotfix/* branch for a back-major release + fetch-depth: 0 + fetch-tags: true + + - 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 + + - 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: ${{ steps.node.outputs.version }} # matches action.yml runs.using + 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 }} + run: | + # 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 diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml new file mode 100644 index 000000000..74f127e99 --- /dev/null +++ b/.github/workflows/rollback.yml @@ -0,0 +1,109 @@ +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 + +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: 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"