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-review` to a fork PR # triggers a deploy against the current head SHA. Subsequent fork pushes # (synchronize) re-use the maintainer's approval for the new head. 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 outputs: preview_url: ${{ steps.deploy.outputs.preview_url }} permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "24" - name: Install frontend dependencies run: pnpm -C apps install --frozen-lockfile - name: Deploy preview environment id: deploy working-directory: apps/backend run: | set -euo pipefail deploy_output="$(go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install --skip-description)" printf '%s\n' "$deploy_output" preview_url="$(printf '%s\n' "$deploy_output" | sed -n 's/^preview URL: //p' | tail -n 1)" test -n "$preview_url" printf 'preview_url=%s\n' "$preview_url" >> "$GITHUB_OUTPUT" env: PR: ${{ github.event.pull_request.number }} SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} # ----------------------------------------------------------------------- # Build the preview CLI from the immutable workflow revision before any # runner checks out or executes fork-controlled content. The fork build job # downloads this artifact only after its lifecycle scripts have completed. # ----------------------------------------------------------------------- build-trusted-preview-cli: if: > github.event.action != 'closed' && github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && ((contains(github.event.pull_request.labels.*.name, 'safe-to-review')) || (vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login)) || (vars.CLAUDE_REVIEW_ALLOWLIST != '' && contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login))) runs-on: ubuntu-latest permissions: {} steps: - name: Check out immutable preview command source without credentials env: REPOSITORY: ${{ github.repository }} REF: ${{ github.workflow_sha }} GIT_TERMINAL_PROMPT: "0" GIT_ASKPASS: /bin/false GITHUB_TOKEN: "" GH_TOKEN: "" run: | set -euo pipefail git init "$GITHUB_WORKSPACE" git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${REPOSITORY}.git" git -C "$GITHUB_WORKSPACE" -c credential.helper= fetch --depth=1 origin "$REF" git -C "$GITHUB_WORKSPACE" checkout --detach FETCH_HEAD - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - name: Build trusted preview command working-directory: apps/backend env: GOWORK: "off" run: go build -o "$RUNNER_TEMP/kandev-preview-deploy" ./cmd/preview - name: Upload trusted preview command uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: trusted-preview-cli-${{ github.run_id }} path: ${{ runner.temp }}/kandev-preview-deploy if-no-files-found: error retention-days: 1 # ----------------------------------------------------------------------- # Build the fork-controlled frontend and collect its inert source/dist input # without credentials. This job ends before the trusted preview command is # downloaded or invoked, so a fork lifecycle script cannot retain a process # that can alter the trusted command. # Three approval paths: # - PREVIEW_ENV_ALLOWLIST repo variable (trusted login list, all events; # JSON array of logins) # - CLAUDE_REVIEW_ALLOWLIST repo variable (trusted login list, all events; # the Claude workflow also adds the approval labels on open) # - `safe-to-review` label (maintainer opt-in, durable until removed). It # authorizes the current and subsequent fork heads for tokenless input # build and trusted artifact deployment. # ----------------------------------------------------------------------- # 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. build-fork-preview: if: > github.event.action != 'closed' && github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && ((contains(github.event.pull_request.labels.*.name, 'safe-to-review')) || (vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login)) || (vars.CLAUDE_REVIEW_ALLOWLIST != '' && contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login))) runs-on: ubuntu-latest permissions: {} steps: - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "24" - name: Check out fork source without credentials # The fork controls its product source and build hooks. This job has no # repository or deployment credentials. env: REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} REF: ${{ github.event.pull_request.head.sha }} GIT_TERMINAL_PROMPT: "0" GIT_ASKPASS: /bin/false GITHUB_TOKEN: "" GH_TOKEN: "" run: | set -euo pipefail git init "$GITHUB_WORKSPACE" git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${REPOSITORY}.git" git -C "$GITHUB_WORKSPACE" -c credential.helper= fetch --depth=1 origin "$REF" git -C "$GITHUB_WORKSPACE" checkout --detach FETCH_HEAD - name: Install frontend dependencies env: GITHUB_TOKEN: "" GH_TOKEN: "" SPRITES_API_TOKEN: "" ACTIONS_ID_TOKEN_REQUEST_TOKEN: "" ACTIONS_RUNTIME_TOKEN: "" run: pnpm -C apps install --frozen-lockfile - name: Build fork frontend env: GITHUB_TOKEN: "" GH_TOKEN: "" SPRITES_API_TOKEN: "" ACTIONS_ID_TOKEN_REQUEST_TOKEN: "" ACTIONS_RUNTIME_TOKEN: "" run: pnpm -C apps --filter @kandev/web build - name: Upload fork preview input uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: # The packager receives only source and already-built static assets. name: fork-preview-input-${{ github.run_id }} path: | apps/backend apps/web/dist if-no-files-found: error retention-days: 1 # ----------------------------------------------------------------------- # Assemble a fork preview in a fresh tokenless runner. No fork checkout or # lifecycle command precedes this job, so only this trusted executable can # start the package operation. # ----------------------------------------------------------------------- package-fork-preview: needs: [build-trusted-preview-cli, build-fork-preview] if: needs.build-trusted-preview-cli.result == 'success' && needs.build-fork-preview.result == 'success' runs-on: ubuntu-latest permissions: {} steps: - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.26.0" - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "24" - name: Record trusted tool paths id: trusted-tools run: | set -euo pipefail pnpm_dir="$(dirname "$(command -v pnpm)")" node_dir="$(dirname "$(command -v node)")" go_dir="$(dirname "$(command -v go)")" printf 'path=%s:%s:%s:/usr/bin:/bin\n' "$pnpm_dir" "$node_dir" "$go_dir" >> "$GITHUB_OUTPUT" - name: Download trusted preview command uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: trusted-preview-cli-${{ github.run_id }} path: ${{ runner.temp }} - name: Download fork preview input uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: fork-preview-input-${{ github.run_id }} path: ${{ runner.temp }}/fork-preview-input - name: Package fork preview artifact working-directory: ${{ runner.temp }}/fork-preview-input/backend env: BASH_ENV: "" TRUSTED_PATH: ${{ steps.trusted-tools.outputs.path }} run: | set -euo pipefail /usr/bin/mkdir -p "$RUNNER_TEMP/kandev-preview-home" /usr/bin/chmod 500 "$RUNNER_TEMP/kandev-preview-deploy" /usr/bin/env -i PATH="$TRUSTED_PATH" HOME="$RUNNER_TEMP/kandev-preview-home" \ "$RUNNER_TEMP/kandev-preview-deploy" package --artifact "$RUNNER_TEMP/kandev-preview.tar.gz" --skip-web-install --skip-web-build (cd "$RUNNER_TEMP" && /usr/bin/sha256sum kandev-preview.tar.gz) > "$RUNNER_TEMP/kandev-preview.tar.gz.sha256" - name: Upload fork preview artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: fork-preview-${{ github.run_id }} path: | ${{ runner.temp }}/kandev-preview.tar.gz ${{ runner.temp }}/kandev-preview.tar.gz.sha256 if-no-files-found: error retention-days: 1 # ----------------------------------------------------------------------- # Deploy a completed fork bundle. This fresh job checks out only the # immutable workflow revision and never runs the fork checkout or its hooks. # ----------------------------------------------------------------------- deploy-fork: needs: package-fork-preview if: needs.package-fork-preview.result == 'success' runs-on: ubuntu-latest outputs: preview_url: ${{ steps.deploy.outputs.preview_url }} permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: repository: ${{ github.repository }} ref: ${{ github.workflow_sha }} fetch-depth: 1 persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - name: Download and validate fork preview artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: fork-preview-${{ github.run_id }} path: ${{ runner.temp }}/fork-preview - name: Validate fork preview artifact env: ARTIFACT: ${{ runner.temp }}/fork-preview/kandev-preview.tar.gz run: | set -euo pipefail cd "$(dirname "$ARTIFACT")" sha256sum --check kandev-preview.tar.gz.sha256 tar -tzf "$ARTIFACT" > /dev/null - name: Deploy preview environment id: deploy working-directory: apps/backend run: | set -euo pipefail deploy_output="$(go run ./cmd/preview deploy --artifact "$ARTIFACT" --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-description)" printf '%s\n' "$deploy_output" preview_url="$(printf '%s\n' "$deploy_output" | sed -n 's/^preview URL: //p' | tail -n 1)" test -n "$preview_url" printf 'preview_url=%s\n' "$preview_url" >> "$GITHUB_OUTPUT" env: PR: ${{ github.event.pull_request.number }} SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} ARTIFACT: ${{ runner.temp }}/fork-preview/kandev-preview.tar.gz SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} # ----------------------------------------------------------------------- # Update the PR description after deployment. # This short trusted job owns the shared description-write lock. Keeping it # separate prevents dependency installation and sprite health checks from # starving walkthrough or reconciliation writers. # ----------------------------------------------------------------------- update-description-same-repo: if: needs.deploy-same-repo.result == 'success' needs: deploy-same-repo runs-on: ubuntu-latest concurrency: group: pr-description-${{ github.event.pull_request.number }} cancel-in-progress: false permissions: contents: read pull-requests: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.workflow_sha }} fetch-depth: 1 persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - name: Update preview description working-directory: apps/backend run: | set -euo pipefail test -n "$PREVIEW_URL" go run ./cmd/preview update-description \ --pr "$PR" \ --sha "$SHA" \ --repo "$REPO" \ --url "$PREVIEW_URL" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} PREVIEW_URL: ${{ needs.deploy-same-repo.outputs.preview_url }} update-description-fork: if: needs.deploy-fork.result == 'success' needs: deploy-fork runs-on: ubuntu-latest concurrency: group: pr-description-${{ github.event.pull_request.number }} cancel-in-progress: false permissions: contents: read pull-requests: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.workflow_sha }} fetch-depth: 1 persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - name: Update preview description working-directory: apps/backend run: | set -euo pipefail test -n "$PREVIEW_URL" go run ./cmd/preview update-description \ --pr "$PR" \ --sha "$SHA" \ --repo "$REPO" \ --url "$PREVIEW_URL" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} PREVIEW_URL: ${{ needs.deploy-fork.outputs.preview_url }} # ----------------------------------------------------------------------- # 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 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # 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" --skip-description env: PR: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }} cleanup-preview-description: if: > always() && github.event.action == 'closed' && github.event_name == 'pull_request_target' && needs.cleanup-preview.result != 'cancelled' needs: cleanup-preview runs-on: ubuntu-latest concurrency: group: pr-description-${{ github.event.pull_request.number }} cancel-in-progress: false permissions: contents: read pull-requests: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.workflow_sha }} fetch-depth: 1 persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - name: Remove preview description working-directory: apps/backend run: go run ./cmd/preview remove-description --pr "$PR" --repo "$REPO" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }}