Promote Vercel production alias #83
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Promote Vercel production alias | |
| on: | |
| deployment_status: | |
| permissions: | |
| actions: read | |
| contents: read | |
| concurrency: | |
| group: vercel-production-alias | |
| cancel-in-progress: false | |
| jobs: | |
| promote: | |
| name: Point studyinchina.vercel.app to the successful main deployment | |
| if: >- | |
| github.event.deployment_status.state == 'success' && | |
| github.event.deployment.environment == 'Production' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 40 | |
| env: | |
| DEPLOYMENT_SHA: ${{ github.event.deployment.sha }} | |
| DEPLOYMENT_URL: ${{ github.event.deployment_status.environment_url }} | |
| steps: | |
| - name: Check out current main | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: main | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| - name: Verify deployment commit is current main | |
| id: main | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| main_sha="$(git rev-parse HEAD)" | |
| if ! [[ "${DEPLOYMENT_SHA}" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "Unexpected deployment SHA: ${DEPLOYMENT_SHA}" >&2 | |
| exit 1 | |
| fi | |
| if [[ "${DEPLOYMENT_SHA}" == "${main_sha}" ]]; then | |
| echo 'matches=true' >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo 'matches=false' >> "$GITHUB_OUTPUT" | |
| echo "::notice::Deployment ${DEPLOYMENT_SHA} is not current main ${main_sha}; the stable alias will not be changed." | |
| - name: Wait for successful CI on the exact deployment SHA | |
| if: steps.main.outputs.matches == 'true' | |
| id: ci | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?branch=main&event=push&per_page=20" | |
| for attempt in $(seq 1 60); do | |
| response="$(curl --fail --silent --show-error \ | |
| --header 'Accept: application/vnd.github+json' \ | |
| --header "Authorization: Bearer ${GITHUB_TOKEN}" \ | |
| --header 'X-GitHub-Api-Version: 2022-11-28' \ | |
| "${api}")" | |
| if jq -e --arg sha "${DEPLOYMENT_SHA}" \ | |
| '.workflow_runs | any(.head_sha == $sha and .status == "completed" and .conclusion == "success")' \ | |
| <<< "${response}" >/dev/null; then | |
| echo 'passed=true' >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if jq -e --arg sha "${DEPLOYMENT_SHA}" \ | |
| '.workflow_runs | any(.head_sha == $sha and .status == "completed" and (.conclusion | IN("failure", "cancelled", "timed_out", "action_required")))' \ | |
| <<< "${response}" >/dev/null; then | |
| echo "::error title=Production promotion blocked::CI completed unsuccessfully for deployment SHA ${DEPLOYMENT_SHA}; the stable alias was not changed." | |
| exit 1 | |
| fi | |
| echo "Waiting for successful CI on ${DEPLOYMENT_SHA} (${attempt}/60)." | |
| sleep 30 | |
| done | |
| echo "::error title=Production promotion timed out::No successful main push CI run was observed for deployment SHA ${DEPLOYMENT_SHA}; the stable alias was not changed." | |
| exit 1 | |
| - name: Reconfirm deployment SHA is still current main | |
| if: >- | |
| steps.main.outputs.matches == 'true' && | |
| steps.ci.outputs.passed == 'true' | |
| id: current | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| ref_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/heads/main" | |
| current_sha="$(curl --fail --silent --show-error \ | |
| --header 'Accept: application/vnd.github+json' \ | |
| --header "Authorization: Bearer ${GITHUB_TOKEN}" \ | |
| --header 'X-GitHub-Api-Version: 2022-11-28' \ | |
| "${ref_api}" | jq -r '.object.sha // empty')" | |
| if ! [[ "${current_sha}" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo '::error title=Production promotion blocked::GitHub did not return a valid current main SHA; the stable alias was not changed.' | |
| exit 1 | |
| fi | |
| if [[ "${DEPLOYMENT_SHA}" == "${current_sha}" ]]; then | |
| echo 'matches=true' >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo 'matches=false' >> "$GITHUB_OUTPUT" | |
| echo "::notice::Main advanced to ${current_sha} while CI was running; deployment ${DEPLOYMENT_SHA} will not receive the stable alias." | |
| - name: Require stable-alias credential | |
| if: >- | |
| steps.current.outputs.matches == 'true' && | |
| steps.ci.outputs.passed == 'true' | |
| id: credential | |
| shell: bash | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -n "${VERCEL_TOKEN}" ]]; then | |
| echo 'configured=true' >> "$GITHUB_OUTPUT" | |
| else | |
| echo '::error title=Stable production alias was not promoted::VERCEL_TOKEN is not configured. The Vercel deployment may be Ready, but studyinchina.vercel.app was not reassigned or smoke-tested by this workflow. See docs/operations/data-maintenance.md#required-github-actions-secrets.' | |
| { | |
| echo '## Stable production alias was not promoted' | |
| echo | |
| echo 'The deployment succeeded, but this workflow cannot reassign or verify `studyinchina.vercel.app` without the `VERCEL_TOKEN` repository secret.' | |
| echo | |
| echo 'This job fails intentionally so a green check cannot be mistaken for a completed production promotion.' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| - name: Validate deployment URL | |
| if: >- | |
| steps.current.outputs.matches == 'true' && | |
| steps.ci.outputs.passed == 'true' && | |
| steps.credential.outputs.configured == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "${DEPLOYMENT_URL}" =~ ^https://studyinchina-[a-z0-9-]+\.vercel\.app/?$ ]]; then | |
| echo "Unexpected Vercel production URL: ${DEPLOYMENT_URL}" >&2 | |
| exit 1 | |
| fi | |
| - name: Verify immutable deployment release API | |
| if: >- | |
| steps.current.outputs.matches == 'true' && | |
| steps.ci.outputs.passed == 'true' && | |
| steps.credential.outputs.configured == 'true' | |
| shell: bash | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| release_api="${DEPLOYMENT_URL%/}/api/v1/releases/current" | |
| deployment_host="${DEPLOYMENT_URL#https://}" | |
| deployment_host="${deployment_host%/}" | |
| # Reuse an existing automation credential through read-only APIs. | |
| # Never disable protection, generate a bypass, or run a package with this token. | |
| project_json="$(curl --fail --silent --show-error \ | |
| --connect-timeout 10 --max-time 30 \ | |
| --header "Authorization: Bearer ${VERCEL_TOKEN}" \ | |
| 'https://api.vercel.com/v9/projects/studyinchina?slug=henry-yangs-projects-c9706eac')" | |
| project_id="$(jq -er 'select(.name == "studyinchina") | .id | select(type == "string" and test("^prj_[A-Za-z0-9]+$"))' <<< "${project_json}")" | |
| team_id="$(jq -er '.accountId | select(type == "string" and test("^team_[A-Za-z0-9]+$"))' <<< "${project_json}")" | |
| deployment_json="$(curl --fail --silent --show-error \ | |
| --connect-timeout 10 --max-time 30 \ | |
| --header "Authorization: Bearer ${VERCEL_TOKEN}" \ | |
| "https://api.vercel.com/v13/deployments/${deployment_host}?teamId=${team_id}")" | |
| if ! jq -e --arg projectId "${project_id}" --arg ownerId "${team_id}" \ | |
| --arg host "${deployment_host}" \ | |
| '.projectId == $projectId and .ownerId == $ownerId | |
| and .url == $host and .readyState == "READY" and .target == "production"' \ | |
| <<< "${deployment_json}" >/dev/null; then | |
| echo 'The immutable URL is not a Ready production deployment of the expected project; the stable alias was not changed.' >&2 | |
| exit 1 | |
| fi | |
| if ! bypass_secret="$(jq -er ' | |
| (.protectionBypass // {}) | to_entries | |
| | map(select(.value.scope == "automation-bypass")) | first | .key | |
| | select(type == "string" and length > 0 and (test("[\\r\\n]") | not)) | |
| ' <<< "${project_json}")"; then | |
| echo 'No existing automation-bypass credential is available. The smoke test will not change Deployment Protection; the stable alias was not changed.' >&2 | |
| exit 1 | |
| fi | |
| # Mask before use; keep the credential in this shell only, never in artifacts. | |
| masked_secret="${bypass_secret//%/%25}" | |
| printf '::add-mask::%s\n' "${masked_secret}" | |
| unset project_json deployment_json masked_secret | |
| for attempt in 1 2 3 4 5 6; do | |
| if curl --fail --silent --show-error \ | |
| --connect-timeout 10 --max-time 30 \ | |
| --header "x-vercel-protection-bypass: ${bypass_secret}" \ | |
| "${release_api}" \ | |
| | jq -e --arg sha "${DEPLOYMENT_SHA}" \ | |
| '.data.deploymentSha == $sha | |
| and (.data.id | type == "string" and length > 0) | |
| and (.data.publicCounts.programs | type == "number" and . > 0)' >/dev/null; then | |
| unset bypass_secret | |
| exit 0 | |
| fi | |
| sleep 10 | |
| done | |
| echo 'The immutable Vercel deployment did not pass the release API smoke test; the stable alias was not changed.' >&2 | |
| exit 1 | |
| - name: Use Node.js 24 | |
| if: >- | |
| steps.current.outputs.matches == 'true' && | |
| steps.ci.outputs.passed == 'true' && | |
| steps.credential.outputs.configured == 'true' | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 | |
| - name: Promote stable production alias transaction and verify release API | |
| if: >- | |
| steps.current.outputs.matches == 'true' && | |
| steps.ci.outputs.passed == 'true' && | |
| steps.credential.outputs.configured == 'true' | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| previous_target='' | |
| mutation_attempted=false | |
| transaction_committed=false | |
| github_main_sha() { | |
| curl --fail --silent --show-error \ | |
| --header 'Accept: application/vnd.github+json' \ | |
| --header "Authorization: Bearer ${GITHUB_TOKEN}" \ | |
| --header 'X-GitHub-Api-Version: 2022-11-28' \ | |
| "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ | |
| | jq -r '.object.sha // empty' | |
| } | |
| current_stable_target() { | |
| npx --yes vercel@58.0.0 alias list \ | |
| --format json \ | |
| --limit 100 \ | |
| --scope henry-yangs-projects-c9706eac \ | |
| --token "${VERCEL_TOKEN}" \ | |
| | jq -er ' | |
| [.aliases[] | select(.alias == "studyinchina.vercel.app")] as $matches | |
| | if ($matches | length) == 1 then $matches[0].url | |
| else error("stable alias must have exactly one current target") | |
| end | |
| ' | |
| } | |
| rollback_on_failure() { | |
| status=$? | |
| trap - EXIT | |
| if [[ "${status}" -eq 0 || "${mutation_attempted}" != 'true' || "${transaction_committed}" == 'true' ]]; then | |
| exit "${status}" | |
| fi | |
| echo "::error title=Stable alias transaction failed::Rolling studyinchina.vercel.app back to ${previous_target}." | |
| if ! npx --yes vercel@58.0.0 alias set \ | |
| "${previous_target}" \ | |
| studyinchina.vercel.app \ | |
| --scope henry-yangs-projects-c9706eac \ | |
| --token "${VERCEL_TOKEN}"; then | |
| echo '::error title=Stable alias rollback failed::The previous target could not be restored; immediate operator action is required.' | |
| exit 1 | |
| fi | |
| for attempt in 1 2 3 4 5 6; do | |
| restored_target="$(current_stable_target || true)" | |
| if [[ "${restored_target}" == "${previous_target}" ]]; then | |
| echo "::notice title=Stable alias rollback verified::studyinchina.vercel.app again targets ${previous_target}." | |
| exit "${status}" | |
| fi | |
| sleep 10 | |
| done | |
| echo "::error title=Stable alias rollback verification failed::Expected ${previous_target}, but the stable alias did not return to that immutable target. Immediate operator action is required." | |
| exit 1 | |
| } | |
| trap rollback_on_failure EXIT | |
| previous_target="$(current_stable_target)" | |
| if ! [[ "${previous_target}" =~ ^studyinchina-[a-z0-9-]+\.vercel\.app$ ]]; then | |
| echo "Unexpected previous stable-alias target: ${previous_target}" >&2 | |
| exit 1 | |
| fi | |
| final_main_sha="$(github_main_sha || true)" | |
| if ! [[ "${final_main_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ "${final_main_sha}" != "${DEPLOYMENT_SHA}" ]]; then | |
| echo "::error title=Production promotion blocked::Main is ${final_main_sha:-unavailable}, not deployment ${DEPLOYMENT_SHA}; the stable alias was not changed." | |
| exit 1 | |
| fi | |
| mutation_attempted=true | |
| npx --yes vercel@58.0.0 alias set \ | |
| "${DEPLOYMENT_URL}" \ | |
| studyinchina.vercel.app \ | |
| --scope henry-yangs-projects-c9706eac \ | |
| --token "${VERCEL_TOKEN}" | |
| post_promotion_main_sha="$(github_main_sha || true)" | |
| if ! [[ "${post_promotion_main_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ "${post_promotion_main_sha}" != "${DEPLOYMENT_SHA}" ]]; then | |
| echo "::error title=Production promotion raced with main::Main advanced to ${post_promotion_main_sha:-unavailable} while the alias was changing." | |
| exit 1 | |
| fi | |
| for attempt in 1 2 3 4 5 6; do | |
| if curl --fail --silent --show-error \ | |
| https://studyinchina.vercel.app/api/v1/releases/current \ | |
| | jq -e --arg sha "${DEPLOYMENT_SHA}" \ | |
| '.data.deploymentSha == $sha | |
| and (.data.id | type == "string" and length > 0) | |
| and (.data.publicCounts.programs | type == "number" and . > 0)' >/dev/null; then | |
| transaction_committed=true | |
| echo "::notice title=Stable alias promotion verified::studyinchina.vercel.app serves deployment ${DEPLOYMENT_SHA}." | |
| exit 0 | |
| fi | |
| sleep 10 | |
| done | |
| echo 'The stable production alias did not pass the release API smoke test; the previous immutable target will be restored.' >&2 | |
| exit 1 |