name: Preview Environment # Dual triggers — mirrors the claude-code-review.yml pattern exactly: # pull_request: same-repo PRs (secrets available, safe to run on PR head) # pull_request_target: fork PRs (secrets available via base repo, gated by allowlist) # Exactly one job runs per PR event based on event_name AND head repo identity. on: pull_request: types: [opened, synchronize, reopened, closed] pull_request_target: # `labeled` is needed so a maintainer applying `safe-to-test` to a fork PR # triggers a deploy against the current head SHA. Subsequent fork pushes # (synchronize) do NOT re-trigger the label path — see deploy-fork's # if-gate and strip-safe-to-test below. types: [opened, synchronize, reopened, closed, labeled] concurrency: group: preview-env-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} # Don't cancel cleanup on close — let it finish destroying the sprite. cancel-in-progress: ${{ github.event.action != 'closed' }} jobs: # ----------------------------------------------------------------------- # Deploy / update preview (same-repo PRs) # Always runs for same-repo pushes that are not a close event. # ----------------------------------------------------------------------- deploy-same-repo: if: > github.event.action != 'closed' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" - name: Install frontend dependencies run: pnpm -C apps install --frozen-lockfile - name: Deploy preview environment working-directory: apps/backend run: go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install env: PR: ${{ github.event.pull_request.number }} SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ----------------------------------------------------------------------- # Deploy / update preview (fork PRs, gated by allowlist) # Uses pull_request_target so secrets are available. # Two approval paths: # - PREVIEW_ENV_ALLOWLIST repo variable (trusted login list, all events; # JSON array of logins) # - `safe-to-test` label (maintainer opt-in, per-commit). Explicitly # blocked on `synchronize` so a contributor's push cannot re-use a # stale label — the maintainer must re-apply it after each push for # the `labeled` event to trigger a fresh deploy of the new SHA. This # matters more here than for claude-code-review.yml because deploy # actually runs the fork's `cmd/preview` code with SPRITES_API_TOKEN. # ----------------------------------------------------------------------- # Branch order matters: keep cheap/safe checks before `fromJSON(vars.*)`, # which throws (and skips the whole job) if the repo variable is # non-empty but unparseable JSON. deploy-fork: if: > github.event.action != 'closed' && github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && ((github.event.action != 'synchronize' && contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || (vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login))) runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v6 with: # pull_request_target checks out base by default; explicitly use PR head. # Safe: the preview CLI only reads files to build artifacts and posts comments. # NOTE: The allowlist gate (safe-to-test, PREVIEW_ENV_ALLOWLIST) # authorizes running the fork's cmd/preview code with SPRITES_API_TOKEN and GH_TOKEN. # The safe-to-test label must be re-applied after each new push (see strip-safe-to-test job). repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" - name: Install frontend dependencies run: pnpm -C apps install --frozen-lockfile - name: Deploy preview environment working-directory: apps/backend run: go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install env: PR: ${{ github.event.pull_request.number }} SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ----------------------------------------------------------------------- # Strip safe-to-test label on new pushes to fork PRs. # Together with the `github.event.action != 'synchronize'` guard on the # label path in deploy-fork's if-gate, this gives true per-commit opt-in: # the maintainer must re-apply `safe-to-test` after each push for the # deploy job to run again. A new push on its own never re-triggers deploy # on the label path. # ----------------------------------------------------------------------- strip-safe-to-test: if: > github.event_name == 'pull_request_target' && github.event.action == 'synchronize' && github.event.pull_request.head.repo.full_name != github.repository && contains(github.event.pull_request.labels.*.name, 'safe-to-test') runs-on: ubuntu-latest permissions: pull-requests: write steps: - uses: actions/github-script@v7 with: script: | try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, name: 'safe-to-test', }); } catch (error) { if (error.status !== 404) throw error; core.info('safe-to-test label already absent; nothing to remove.'); } # ----------------------------------------------------------------------- # Cleanup (all closed PRs — no allowlist check) # The sprite must be destroyed regardless of how the PR was closed or # whether a preview was ever successfully deployed. Handles gracefully # if the sprite does not exist (was never deployed or already destroyed). # Runs only on pull_request_target to avoid duplicate runs — both events # fire `closed`, but only pull_request_target has secrets for all PRs. # ----------------------------------------------------------------------- cleanup-preview: if: > github.event.action == 'closed' && github.event_name == 'pull_request_target' runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - name: Destroy preview environment working-directory: apps/backend run: go run ./cmd/preview cleanup --pr "$PR" --repo "$REPO" env: PR: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}