name: Preview

# Per-PR preview environment (M16, repointed to the TS server in #805). On a PR to main this
# workflow stands up the browsable, isolated per-PR stack, in four jobs:
#
#   provision     (M16-1) - create an isolated Neon branch database named preview/pr-<number>
#     (copy-on-write, free-tier), apply the app's schema plus the deterministic seed
#     (apps/server/scripts/provision-preview-db.ts - the server's own boot-time store mechanism,
#     not a parallel migration system), and hand the branch's pooled connection URL to
#     deploy-server as a short-lived artifact and a job output.
#   deploy-server (M16-2) - build THAT PR's apps/server image, push it to Artifact Registry
#     tagged pr-<number>, and deploy it to a per-PR Cloud Run service (bloom-server-pr-<number>)
#     pointed at the branch database, yielding a reachable per-PR HTTPS URL for deploy-web.
#   deploy-web    (M16-3) - build THAT PR's dashboard with its /api proxy baked to point at the
#     PR's server (Pages env vars are per-project, not per-deployment) and publish it to the
#     existing Cloudflare Pages project as the pr-<number> branch preview, whose stable alias URL
#     is the human-facing entry point to the whole preview stack.
#   deploy-docs   (M45)   - build THAT PR's static Docusaurus docs site and publish it to the
#     bloom-docs Pages project as the pr-<number> branch preview. Independent of the server stack
#     (no DB, no API), so it runs in parallel with provision/deploy-server/deploy-web.
#   comment       - post the dashboard + server + docs preview URLs (and the per-PR test-login password
#     that gates the otherwise publicly reachable preview's test-login) back to the PR as a single
#     sticky comment (updated in place on every push, never a new comment per run), so a reviewer
#     opens the running preview straight from the PR instead of digging into this workflow run.
#     Reads the URLs from the deploy-web/deploy-server artifacts (not job outputs), so a service
#     URL that embeds a secret-like value can never block the hand-off.
#
# Connection strings are masked and never printed to logs. Idempotent: a re-run (a new push to the
# PR) reuses the existing Neon branch and rolls the same Cloud Run service to a new revision.
# Teardown is #172's job (Neon REST delete + gcloud run services delete on PR close). The
# pre-merge e2e lane (e2e-preview.yml) runs the same TS server in-runner over an ephemeral
# in-memory database.
#
# Setup (already configured, no dashboard steps): repo secrets NEON_API_KEY + NEON_PROJECT_ID
# (Neon free-tier branching), GCP_SA_KEY + GCP_PROJECT_ID + GCP_REGION + GCP_AR_REPO (the
# bloom-ci-deployer service account and the Artifact Registry docker repo), and the existing
# Cloudflare Pages setup shared with deploy-web-dev.yml (CLOUDFLARE_API_TOKEN secret,
# CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_PAGES_PROJECT repo vars).
# See docs/preview-environments.md.

on:
  pull_request:
    branches: [main]
    types: [opened, reopened, synchronize]
  workflow_dispatch:
    inputs:
      pr:
        description: "PR number to provision the preview environment for"
        required: true
        type: number

concurrency:
  group: preview-${{ github.event.pull_request.number || inputs.pr }}
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  provision:
    runs-on: bloom-arc
    timeout-minutes: 20
    outputs:
      # Non-secret handle for downstream jobs (deploy-server, and #172's teardown): the branch
      # name. The connection string itself travels only via the masked artifact (it embeds the
      # password).
      branch-name: ${{ steps.branch.outputs.name }}
    steps:
      - uses: actions/checkout@v4

      - name: Compute the branch name
        id: branch
        run: echo "name=preview/pr-${{ github.event.pull_request.number || inputs.pr }}" >> "$GITHUB_OUTPUT"

      # create-branch-action is idempotent: if the branch already exists it returns that branch
      # (created=false) instead of erroring, so a PR push (synchronize) safely reuses it.
      # We deliberately do NOT set suspend_timeout: the free plan rejects a custom suspend
      # interval ("modifying the suspend interval is not permitted on this account", HTTP 412),
      # and the plan already auto-suspends idle branch compute to zero (~5 min) to conserve the
      # free tier's compute-hours; a query wakes it in ~1s. Overriding it was never needed.
      - name: Create (or reuse) the Neon preview branch
        id: neon
        uses: neondatabase/create-branch-action@v6
        with:
          project_id: ${{ secrets.NEON_PROJECT_ID }}
          api_key: ${{ secrets.NEON_API_KEY }}
          branch_name: ${{ steps.branch.outputs.name }}

      - name: Mask and record the branch connection strings
        env:
          DB_URL: ${{ steps.neon.outputs.db_url }}
          DB_URL_POOLED: ${{ steps.neon.outputs.db_url_pooled }}
        run: |
          set -euo pipefail
          # Mask before anything can echo them: the URLs embed the branch DB password.
          echo "::add-mask::$DB_URL"
          echo "::add-mask::$DB_URL_POOLED"
          {
            echo "PREVIEW_BRANCH_NAME=${{ steps.branch.outputs.name }}"
            echo "PREVIEW_DATABASE_URL=$DB_URL_POOLED"
            echo "PREVIEW_DATABASE_URL_DIRECT=$DB_URL"
          } > preview-db.env

      # --- Schema + seed (the server's own boot-time mechanism; see the provision script) ---
      - uses: pnpm/action-setup@v4
        with:
          version: 10.6.3

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      # Workspace-root install (single lockfile, #481); the provisioner runs from apps/server.
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Direct (non-pooled) URL for DDL: schema creation runs cleaner off the pooler.
      - name: Apply schema and seed to the branch database
        working-directory: apps/server
        env:
          DB_URL: ${{ steps.neon.outputs.db_url }}
        run: BLOOM_SUPABASE_DB_URL="$DB_URL" pnpm run provision:preview-db

      # Published only after schema + seed succeeded, so downstream never sees a half-provisioned
      # database. 1-day retention: deploy-server consumes it within the same run, and it can
      # always be re-derived by re-running this workflow.
      - name: Upload the connection info for the deploy-server job
        uses: actions/upload-artifact@v4
        with:
          name: preview-db-pr-${{ github.event.pull_request.number || inputs.pr }}
          path: preview-db.env
          retention-days: 1
          if-no-files-found: error
          overwrite: true

  # --- Server preview (M16-2, #170): the PR's image on Cloud Run against the branch DB above ---
  deploy-server:
    runs-on: bloom-arc
    timeout-minutes: 25
    needs: provision
    outputs:
      # The per-PR HTTPS URL for downstream (#171). Also published as the preview-server-pr-<n>
      # artifact: if the service URL ever embeds a value registered as a secret (e.g. a region
      # string in Cloud Run's newer deterministic URL format), the runner refuses to pass it as a
      # job output ("skip output ... may contain secret") - the artifact is the durable hand-off.
      server-url: ${{ steps.url.outputs.url }}
    steps:
      - uses: actions/checkout@v4
        with:
          # pull_request runs check out the PR merge ref as usual. A manual dispatch would check
          # out the default branch, but this job must build THAT PR's image - point at the PR head.
          ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr) || '' }}

      # The image path and service name embed values stored as repo secrets (project id, region),
      # so they go through GITHUB_ENV (masked in logs) rather than step outputs.
      - name: Compute the per-PR image reference and service name
        env:
          GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
          GCP_REGION: ${{ secrets.GCP_REGION }}
          GCP_AR_REPO: ${{ secrets.GCP_AR_REPO }}
        run: |
          set -euo pipefail
          pr="${{ github.event.pull_request.number || inputs.pr }}"
          {
            echo "PR_NUMBER=$pr"
            echo "SERVICE_NAME=bloom-server-pr-$pr"
            echo "PREVIEW_IMAGE=${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GCP_AR_REPO}/bloom-server:pr-$pr"
          } >> "$GITHUB_ENV"

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Authenticate Docker to Artifact Registry
        env:
          GCP_REGION: ${{ secrets.GCP_REGION }}
        run: gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # apps/server/Dockerfile builds from the REPO ROOT context (the pnpm workspace is the
      # build input - see the Dockerfile header); the adjacent Dockerfile.dockerignore allowlists
      # what the build needs. The pr-<n> tag lives only in the preview Artifact Registry repo and
      # is overwritten on each push to the PR (idempotent); the gha cache keeps PR builds warm.
      - name: Build the server image and push pr-${{ github.event.pull_request.number || inputs.pr }} to Artifact Registry
        uses: docker/build-push-action@v6
        with:
          context: .
          file: apps/server/Dockerfile
          push: true
          tags: ${{ env.PREVIEW_IMAGE }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Download the preview DB connection info
        uses: actions/download-artifact@v4
        with:
          name: preview-db-pr-${{ github.event.pull_request.number || inputs.pr }}

      # Re-mask in THIS job before anything can echo it: masks do not carry across jobs, and the
      # pooled URL embeds the branch DB password.
      - name: Read and mask the preview DB URL
        run: |
          set -euo pipefail
          db_url="$(grep -m1 '^PREVIEW_DATABASE_URL=' preview-db.env | cut -d= -f2-)"
          if [ -z "$db_url" ]; then
            echo "::error::preview-db.env is missing PREVIEW_DATABASE_URL" >&2
            exit 1
          fi
          echo "::add-mask::$db_url"
          echo "PREVIEW_DATABASE_URL=$db_url" >> "$GITHUB_ENV"

      # The preview URLs are publicly reachable even though the repo is private, so an open
      # test-login would let anyone who finds one mint a session (#346 follow-up). Gate it behind
      # a per-PR throwaway secret: generated here, handed to the server below, and delivered to
      # reviewers only via collaborator-only surfaces (the sticky PR comment / this run's
      # artifact). Masked before anything can echo it.
      - name: Generate the per-PR test-login password
        run: |
          set -euo pipefail
          PW="$(openssl rand -hex 16)"
          echo "::add-mask::$PW"
          echo "PREVIEW_TEST_LOGIN_PASSWORD=$PW" >> "$GITHUB_ENV"

      # One Cloud Run service per PR: deploy updates it in place (a new revision) when it already
      # exists, and #172 tears it down by name on PR close. Runs AS the deployer service account on
      # purpose: the project has no Compute default SA (Compute API not enabled) and this SA cannot
      # create one; acceptable because previews are ephemeral and the Bloom server makes no Google
      # Cloud API calls at runtime, so the SA's roles are never exercised by the app.
      #
      # Env mirrors the e2e-preview lane, but on the durable branch DB: fake LLM (deterministic, no
      # real keys - never prod secrets, unchanged from the Python deploy this replaces), supabase
      # store backend pointed at the pooled branch URL, and the non-production test-login ON -
      # hardened behind the per-PR password above - so a collaborator can sign in to the browsable
      # preview but a stranger who finds the URL cannot. The TS server reads the exact same BLOOM_*
      # keys (src/config.ts / src/settings.ts mirror the Python settings). --port 8090 matches the
      # apps/server Dockerfile's EXPOSE/BLOOM_SERVER_PORT default. ^|^ swaps --set-env-vars' comma
      # delimiter for | (never valid in a URL), so a comma in the DB URL's query params cannot
      # split the value.
      - name: Deploy to Cloud Run
        env:
          GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
          GCP_REGION: ${{ secrets.GCP_REGION }}
        run: |
          set -euo pipefail
          gcloud run deploy "$SERVICE_NAME" \
            --image "$PREVIEW_IMAGE" \
            --region "$GCP_REGION" \
            --allow-unauthenticated \
            --port 8090 \
            --service-account "bloom-ci-deployer@${GCP_PROJECT_ID}.iam.gserviceaccount.com" \
            --min-instances=0 \
            --max-instances=1 \
            --set-env-vars "^|^BLOOM_ENV=development|BLOOM_STORE_BACKEND=supabase|BLOOM_SUPABASE_DB_URL=${PREVIEW_DATABASE_URL}|BLOOM_ENABLE_TEST_LOGIN=true|BLOOM_TEST_LOGIN_PASSWORD=${PREVIEW_TEST_LOGIN_PASSWORD}|BLOOM_LLM_PROVIDER=fake" \
            --quiet

      # The URL is stable per service, so a re-run converges on the same address. The health poll
      # tolerates the scale-to-zero cold start (the first request spins up the instance).
      - name: Capture the preview URL and verify health
        id: url
        env:
          GCP_REGION: ${{ secrets.GCP_REGION }}
        run: |
          set -euo pipefail
          url="$(gcloud run services describe "$SERVICE_NAME" --region "$GCP_REGION" --format='value(status.url)')"
          if [ -z "$url" ]; then
            echo "::error::could not read the deployed service URL" >&2
            exit 1
          fi
          for _ in $(seq 1 30); do
            curl -fsS "$url/health" >/dev/null 2>&1 && break
            sleep 2
          done
          curl -fsS "$url/health" >/dev/null || {
            echo "::error::server preview did not become healthy at $url/health" >&2
            exit 1
          }
          echo "url=$url" >> "$GITHUB_OUTPUT"
          {
            echo "PREVIEW_SERVER_URL=$url"
            echo "PREVIEW_PR=$PR_NUMBER"
            echo "PREVIEW_TEST_LOGIN_PASSWORD=$PREVIEW_TEST_LOGIN_PASSWORD"
          } > preview-server.env

      # 1-day retention, like preview-db.env: #171 and the comment job consume it within the same
      # PR event, and it is re-derived by any re-run. Also carries the test-login password to the
      # comment job - artifacts are the durable hand-off for values the runner refuses to pass as
      # job outputs (registered secrets).
      - name: Upload the preview server URL for downstream jobs (#171)
        uses: actions/upload-artifact@v4
        with:
          name: preview-server-pr-${{ github.event.pull_request.number || inputs.pr }}
          path: preview-server.env
          retention-days: 1
          if-no-files-found: error
          overwrite: true

  # --- Web preview (M16-3, #171): the PR's dashboard on Pages, its /api proxy -> the PR's server ---
  deploy-web:
    runs-on: bloom-arc
    timeout-minutes: 15
    needs: deploy-server
    outputs:
      # The stable per-PR web preview URL (the pr-<n> branch alias) for humans and #172. Also
      # published as the preview-web-pr-<n> artifact, mirroring the db/server hand-offs.
      web-url: ${{ steps.url.outputs.url }}
    steps:
      - uses: actions/checkout@v4
        with:
          # Same PR-head pinning as deploy-server: a manual dispatch must build THAT PR's dashboard.
          ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr) || '' }}

      - name: Download the preview server URL
        uses: actions/download-artifact@v4
        with:
          name: preview-server-pr-${{ github.event.pull_request.number || inputs.pr }}

      # The Cloud Run URL is non-secret (unlike the DB URL - no masking needed); GITHUB_ENV just
      # carries it across steps.
      - name: Read the preview server URL
        run: |
          set -euo pipefail
          server_url="$(grep -m1 '^PREVIEW_SERVER_URL=' preview-server.env | cut -d= -f2-)"
          if [ -z "$server_url" ]; then
            echo "::error::preview-server.env is missing PREVIEW_SERVER_URL" >&2
            exit 1
          fi
          {
            echo "PREVIEW_SERVER_URL=$server_url"
            echo "PR_NUMBER=${{ github.event.pull_request.number || inputs.pr }}"
          } >> "$GITHUB_ENV"

      # --- Build (same toolchain and steps as deploy-web-dev.yml) ---
      - uses: pnpm/action-setup@v4
        with:
          version: 10.6.3

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      # Workspace-root install (single lockfile, #481); build + publish stay in apps/web.
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Pages env vars (API_ORIGIN) are scoped per project, not per deployment, so the PR's server
      # origin is baked into this deployment's Function bundle instead: overwrite the committed
      # null override that the /api proxy prefers over API_ORIGIN (see preview-api-origin.ts).
      - name: Point the /api proxy at the PR's server
        working-directory: apps/web
        run: |
          printf 'export const PREVIEW_API_ORIGIN: string | null = "%s";\n' "$PREVIEW_SERVER_URL" > preview-api-origin.ts

      # Same-origin build (empty VITE_API_BASE_URL), exactly like the dev deploy: the browser
      # talks only to the Pages origin and the Function above carries /api to the PR's server, so
      # the session cookie stays first-party.
      - name: Build
        working-directory: apps/web
        env:
          VITE_API_BASE_URL: ""
        run: pnpm run build

      # Reuses the existing Pages project and token (same pattern as deploy-web-dev.yml, but
      # --branch=pr-<n> instead of main, which makes this a PREVIEW deployment). Idempotent: a
      # re-run redeploys the same branch, and the stable branch alias
      # https://pr-<n>.<project>.pages.dev always points at the latest build. #172 tears the
      # pr-<n> deployments down by that branch name.
      - name: Publish to Cloudflare Pages (preview)
        id: pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          workingDirectory: apps/web
          packageManager: pnpm
          command: >-
            pages deploy dist
            --project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT || 'bloom-dashboard' }}
            --branch=pr-${{ github.event.pull_request.number || inputs.pr }}

      # Prefer the stable branch alias (same URL on every re-run) over the per-deployment hash
      # URL. The probe goes through the deployed Function: /api/auth/me answering 401 (or 200)
      # proves web -> /api proxy -> live server end-to-end without side effects - the server's
      # health route is not under /api, and any proxy failure surfaces as a Function 5xx instead.
      # The poll tolerates alias propagation and the server's scale-to-zero cold start.
      - name: Capture the web preview URL and verify /api end-to-end
        id: url
        env:
          ALIAS_URL: ${{ steps.pages.outputs.pages-deployment-alias-url }}
          DEPLOYMENT_URL: ${{ steps.pages.outputs.deployment-url }}
        run: |
          set -euo pipefail
          url="${ALIAS_URL:-$DEPLOYMENT_URL}"
          if [ -z "$url" ]; then
            echo "::error::wrangler reported no deployment URL" >&2
            exit 1
          fi
          probe() {
            code="$(curl -s -o /dev/null -w '%{http_code}' "$url/api/auth/me" || true)"
            [ "$code" = "200" ] || [ "$code" = "401" ]
          }
          ok=false
          for _ in $(seq 1 30); do
            if probe; then ok=true; break; fi
            sleep 2
          done
          if [ "$ok" != "true" ]; then
            echo "::error::web preview at $url did not reach the PR server via /api (last status: ${code:-none})" >&2
            exit 1
          fi
          echo "Web preview is live at $url (server: $PREVIEW_SERVER_URL)"
          echo "url=$url" >> "$GITHUB_OUTPUT"
          {
            echo "PREVIEW_WEB_URL=$url"
            echo "PREVIEW_PR=$PR_NUMBER"
          } > preview-web.env

      # 1-day retention like the db/server hand-offs: humans and #172 consume it within the PR's
      # lifetime, and any re-run re-derives it.
      - name: Upload the web preview URL
        uses: actions/upload-artifact@v4
        with:
          name: preview-web-pr-${{ github.event.pull_request.number || inputs.pr }}
          path: preview-web.env
          retention-days: 1
          if-no-files-found: error
          overwrite: true

  # --- Docs preview (M45 follow-up): the PR's Docusaurus docs site on Pages ---
  # The docs app is static (the canonical docs/ tree + the generated TypeDoc reference) with no
  # server or database, so this job is independent of the provision -> deploy-server -> deploy-web
  # stack and runs in parallel with it. It publishes THAT PR's build to the SAME bloom-docs Pages
  # project the dev deploy uses (deploy-docs-dev.yml), as the pr-<n> branch preview, so a reviewer
  # sees the rendered docs a PR produces before it reaches main. #172 tears the pr-<n> docs
  # deployments down alongside the dashboard's (preview-teardown.yml).
  deploy-docs:
    runs-on: bloom-arc
    timeout-minutes: 15
    outputs:
      # The stable per-PR docs preview URL (the pr-<n> branch alias) for the comment job and #172.
      docs-url: ${{ steps.url.outputs.url }}
    steps:
      - uses: actions/checkout@v4
        with:
          # Same PR-head pinning as the other deploy jobs: a manual dispatch must build THAT PR's docs.
          ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr) || '' }}

      - uses: pnpm/action-setup@v4
        with:
          version: 10.6.3

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      # Workspace-root install (single lockfile, #481); build + publish stay in apps/docs. The build
      # also generates the TypeDoc reference from packages/*, which the root install provides (M45-4).
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        working-directory: apps/docs
        run: pnpm run build

      # Reuses the existing bloom-docs Pages project and token (same pattern as deploy-docs-dev.yml,
      # but --branch=pr-<n> instead of main, which makes this a PREVIEW deployment). Docusaurus emits
      # to apps/docs/build (not dist). Idempotent: a re-run redeploys the same branch, and the stable
      # branch alias https://pr-<n>.bloom-docs.pages.dev always points at the latest build.
      - name: Publish to Cloudflare Pages (preview)
        id: pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          workingDirectory: apps/docs
          packageManager: pnpm
          command: >-
            pages deploy build
            --project-name=${{ vars.CLOUDFLARE_DOCS_PAGES_PROJECT || 'bloom-docs' }}
            --branch=pr-${{ github.event.pull_request.number || inputs.pr }}

      # Prefer the stable branch alias (same URL on every re-run) over the per-deployment hash URL.
      # A static site has no /api to probe, so verify the root serves 200 (baseUrl is "/"); the poll
      # tolerates alias propagation.
      - name: Capture the docs preview URL and verify it serves
        id: url
        env:
          ALIAS_URL: ${{ steps.pages.outputs.pages-deployment-alias-url }}
          DEPLOYMENT_URL: ${{ steps.pages.outputs.deployment-url }}
        run: |
          set -euo pipefail
          url="${ALIAS_URL:-$DEPLOYMENT_URL}"
          if [ -z "$url" ]; then
            echo "::error::wrangler reported no deployment URL" >&2
            exit 1
          fi
          ok=false
          for _ in $(seq 1 30); do
            code="$(curl -s -o /dev/null -w '%{http_code}' "$url/" || true)"
            if [ "$code" = "200" ]; then ok=true; break; fi
            sleep 2
          done
          if [ "$ok" != "true" ]; then
            echo "::error::docs preview at $url did not serve 200 (last status: ${code:-none})" >&2
            exit 1
          fi
          echo "Docs preview is live at $url"
          echo "url=$url" >> "$GITHUB_OUTPUT"
          {
            echo "PREVIEW_DOCS_URL=$url"
            echo "PREVIEW_PR=${{ github.event.pull_request.number || inputs.pr }}"
          } > preview-docs.env

      # 1-day retention like the other preview hand-offs: the comment job consumes it within the PR
      # event, and any re-run re-derives it.
      - name: Upload the docs preview URL
        uses: actions/upload-artifact@v4
        with:
          name: preview-docs-pr-${{ github.event.pull_request.number || inputs.pr }}
          path: preview-docs.env
          retention-days: 1
          if-no-files-found: error
          overwrite: true

  # --- Surface the preview URLs on the PR (#344 follow-up, Al 2026-08-08) ---
  # The deploy jobs only stash the URLs as job outputs + artifacts, so a reviewer had to open this
  # workflow run to find where the preview lives. This upserts ONE sticky comment on the PR carrying
  # the URLs, refreshed in place on every push. It reads the URLs from the deploy-web/deploy-server/
  # deploy-docs artifacts rather than job outputs: a Cloud Run URL can embed a value registered as a
  # secret, which the runner refuses to pass as a job output, and the artifacts are the durable hand-off.
  comment:
    runs-on: bloom-arc
    timeout-minutes: 5
    needs: [deploy-server, deploy-web, deploy-docs]
    # Only this job needs to write to the PR; the top-level grant stays read-only.
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Download the web preview URL hand-off
        uses: actions/download-artifact@v4
        with:
          name: preview-web-pr-${{ github.event.pull_request.number || inputs.pr }}

      - name: Download the server preview URL hand-off
        uses: actions/download-artifact@v4
        with:
          name: preview-server-pr-${{ github.event.pull_request.number || inputs.pr }}

      - name: Download the docs preview URL hand-off
        uses: actions/download-artifact@v4
        with:
          name: preview-docs-pr-${{ github.event.pull_request.number || inputs.pr }}

      # The test-login password is masked in THIS job's logs (masks do not carry across jobs) but
      # still lands verbatim in the PR comment below - acceptable because the repo is private, so
      # PR comments are a collaborator-only surface; it never reaches an always-public one.
      - name: Read the preview URLs
        id: urls
        run: |
          set -euo pipefail
          web="$(grep -m1 '^PREVIEW_WEB_URL=' preview-web.env | cut -d= -f2-)"
          server="$(grep -m1 '^PREVIEW_SERVER_URL=' preview-server.env | cut -d= -f2-)"
          docs="$(grep -m1 '^PREVIEW_DOCS_URL=' preview-docs.env | cut -d= -f2-)"
          pw="$(grep -m1 '^PREVIEW_TEST_LOGIN_PASSWORD=' preview-server.env | cut -d= -f2- || true)"
          if [ -z "$web" ] || [ -z "$server" ] || [ -z "$docs" ] || [ -z "$pw" ]; then
            echo "::error::missing preview hand-off value(s) (web='$web' server='$server' docs='$docs' pw-present=$([ -n "$pw" ] && echo yes || echo no))" >&2
            exit 1
          fi
          echo "::add-mask::$pw"
          {
            echo "web=$web"
            echo "server=$server"
            echo "docs=$docs"
            echo "test-login-password=$pw"
          } >> "$GITHUB_OUTPUT"

      # marocchino/sticky-pull-request-comment: maintained (MIT) create-or-update-by-header comment
      # action - avoids hand-rolling the find-and-update dance and keeps it to a single comment.
      - name: Upsert the sticky preview comment
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          number: ${{ github.event.pull_request.number || inputs.pr }}
          header: preview-environment
          message: |
            ### 🔎 Preview environment

            | Surface | URL |
            | --- | --- |
            | 🖥️ Dashboard (web) | ${{ steps.urls.outputs.web }} |
            | ⚙️ API (bloom-server) | ${{ steps.urls.outputs.server }} |
            | 📚 Docs (Docusaurus) | ${{ steps.urls.outputs.docs }} |

            Test-login password (per-PR, throwaway): `${{ steps.urls.outputs.test-login-password }}`

            Ephemeral per-PR stack (Neon branch DB + Cloud Run TS server + Cloudflare Pages `pr-${{ github.event.pull_request.number || inputs.pr }}`). Test-login is enabled but requires the password above, and the data is throwaway. Torn down when the PR closes.
