From 2649f26438c89b5939dade18231759aa4ab5001f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 06:47:17 +0000 Subject: [PATCH] Record the Preview deployment against the pull request head The merge gate on main is branch protection's "require deployments to succeed before merging", naming the `Preview` environment - not a status check. GitHub looks for a successful, active `Preview` deployment on the PULL REQUEST HEAD commit. Splitting the preview deploy into a `workflow_run` job moved `environment: Preview` onto a job whose run SHA is main, so GitHub now records that deployment against main and the pull request head gets none. Every pull request is left on "Missing successful active Preview deployment" even with all checks green. Create the deployment explicitly against `workflow_run.head_sha` through the Deployments API, alongside the existing check run, and complete it under `always()` on every path - success with the preview URL, failure otherwise - so it can never be left in progress. `auto_inactive: false` on those statuses stops one pull request's preview from flipping every other pull request's Preview deployment to `inactive`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NPMyesYBAqXnBx531rhVbY --- .../firebase-hosting-preview-deploy.yml | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/.github/workflows/firebase-hosting-preview-deploy.yml b/.github/workflows/firebase-hosting-preview-deploy.yml index 68fd1c28d..cd56e712f 100644 --- a/.github/workflows/firebase-hosting-preview-deploy.yml +++ b/.github/workflows/firebase-hosting-preview-deploy.yml @@ -16,7 +16,22 @@ # NOTE: GitHub only ever triggers `workflow_run` from the copy of this file on # the default branch, so this workflow does nothing until it is merged to main. # -# CHECK RUN: `Deploy Preview` is a required status check on main. It used to be +# MERGE GATE: what actually blocks merging into main is branch protection's +# "require deployments to succeed before merging", naming the `Preview` +# environment - NOT a status check. GitHub looks for a successful, *active* +# `Preview` deployment recorded against the PULL REQUEST HEAD commit. +# +# That is the one thing a `workflow_run` job does not get for free. The run's +# SHA here is main, so the deployment GitHub creates for the job-level +# `environment: Preview` is recorded against main and does nothing for the pull +# request, leaving it stuck on "Missing successful active Preview deployment". +# This workflow therefore creates and completes a `Preview` deployment on +# `workflow_run.head_sha` itself, through the Deployments API. +# +# CHECK RUN: `Deploy Preview` is *not* the merge gate - the deployment above is. +# Branch protection cannot be read from inside a workflow, so the check run is +# still reported in case it is required too; it is cheap and it mirrors what the +# previous workflow produced. It used to be # created by FirebaseExtended/action-hosting-deploy, but that action only does # so when it can see a `pull_request` event payload: # @@ -41,6 +56,7 @@ permissions: contents: read actions: read # required to download an artifact from another workflow run checks: write # required to report the `Deploy Preview` check run + deployments: write # required to record the `Preview` deployment that gates merging pull-requests: write # required to post the preview comment jobs: @@ -74,6 +90,53 @@ jobs: }) core.setOutput('id', data.id) + # THE MERGE GATE. Opened next to the check run and before anything can + # fail, so every path below has a deployment to close. The job-level + # `environment: Preview` further down makes GitHub record its own Preview + # deployment, but against main (the `workflow_run` SHA), which the pull + # request cannot use - this is the one branch protection evaluates. + - name: Open the Preview deployment on the pull request head + id: deployment + uses: actions/github-script@v7 + env: + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + with: + script: | + const { owner, repo } = context.repo + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` + // `required_contexts: []` is load-bearing: with the default, GitHub + // waits on the commit's other checks and answers with a queued + // "merged"/"conflict" message instead of creating a deployment. + // `transient_environment`/`production_environment` are both false, + // matching the deployments the previous workflow produced - the + // shape branch protection is known to accept here. + const { data } = await github.rest.repos.createDeployment({ + owner, + repo, + ref: process.env.HEAD_SHA, + environment: 'Preview', + auto_merge: false, + required_contexts: [], + transient_environment: false, + production_environment: false, + description: 'Firebase Hosting preview for this pull request', + }) + if (!data || !data.id) { + throw new Error( + `GitHub did not create a deployment: ${JSON.stringify(data)}` + ) + } + core.setOutput('id', data.id) + await github.rest.repos.createDeploymentStatus({ + owner, + repo, + deployment_id: data.id, + state: 'in_progress', + log_url: runUrl, + description: 'Deploying the Firebase Hosting preview', + auto_inactive: false, + }) + # The build failed, so there is nothing to deploy. Fail the job here, # before any artifact is downloaded or any secret is used: GitHub skips # the remaining steps and the closing step at the bottom reports the @@ -285,3 +348,53 @@ jobs: details_url: previewUrl || runUrl, output: { title, summary }, }) + + # THE ONLY PLACE THE PREVIEW DEPLOYMENT IS COMPLETED. Deliberately the last + # step in the job: GitHub records its own Preview deployment (against main) + # for the job-level `environment:` when the job ends, and ordering this + # last keeps the pull request head deployment the most recent successful + # one for the environment. That ordering is load-bearing. + # + # `auto_inactive: false` stops this status from flipping every other pull + # request's Preview deployment to `inactive`, which is what leaves older + # pull requests stuck on "Missing successful active Preview deployment" + # long after their own preview deployed fine. + # + # `always()` is essential for the same reason it is on the check run: a + # deployment left `in_progress` blocks the pull request with nothing to + # click. The `id` guard means a failure to create the deployment does not + # try to close one that does not exist. + - name: Close the Preview deployment on the pull request head + if: always() && steps.deployment.outputs.id != '' + uses: actions/github-script@v7 + env: + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + JOB_STATUS: ${{ job.status }} + DETAILS_URL: ${{ steps.deploy.outputs.details_url }} + URLS: ${{ steps.deploy.outputs.urls }} + with: + script: | + const { owner, repo } = context.repo + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` + const previewUrl = (process.env.DETAILS_URL || process.env.URLS || '').trim() + const state = process.env.JOB_STATUS === 'success' ? 'success' : 'failure' + + const status = { + owner, + repo, + deployment_id: Number(process.env.DEPLOYMENT_ID), + state, + log_url: runUrl, + description: + state === 'success' + ? 'Preview deployed' + : 'The preview deploy did not succeed', + auto_inactive: false, + } + // environment_url has to be a real URL or the API rejects the whole + // status, which would leave the deployment in_progress forever. + if (state === 'success' && /^https?:\/\//.test(previewUrl)) { + status.environment_url = previewUrl + } + + await github.rest.repos.createDeploymentStatus(status)