Files
kandev/.github/workflows/preview-env.yml
T
2026-09-07 19:48:11 +01:00

489 lines
20 KiB
YAML

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 }}