Skip to content

feat: CLI backend auth and same-account iroh auto-trust #1775

feat: CLI backend auth and same-account iroh auto-trust

feat: CLI backend auth and same-account iroh auto-trust #1775

Workflow file for this run

name: Preview Deploy
# Creates an ephemeral preview stack on AWS Fargate for each pull request opened
# from a branch in this repo. Five Cloudflare subdomains are created per PR,
# mirroring prod's subdomain structure:
# thunderbolt-pr-{N}.preview.thunderbolt.io → marketing
# app-pr-{N}.preview.thunderbolt.io → app frontend
# api-pr-{N}.preview.thunderbolt.io → backend API
# auth-pr-{N}.preview.thunderbolt.io → keycloak OIDC
# powersync-pr-{N}.preview.thunderbolt.io → powersync
#
# Fork PRs are supported via the `pull_request_target` trigger, gated behind
# the `fork-preview-approval` environment so a maintainer must explicitly
# approve each run (including each new push). Internal (same-repo) PRs use
# `pull_request` and auto-deploy with no human gate, same as before.
#
# Trust boundary: under `pull_request_target`, the workflow file is read from
# main (so a fork cannot modify how the deploy behaves) and the default
# `actions/checkout` in `stack-deploy.yml` picks up main's Pulumi program —
# meaning fork PRs deploy main's infrastructure code with the fork's built
# images. The maintainer-approval gate is the human check on what's in those
# images; nothing on the runner ever executes fork shell scripts with secrets.
#
# Also supports workflow_dispatch for manual testing from any branch.
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
pull_request_target:
types: [opened, synchronize, reopened]
branches: [main]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to deploy a preview for'
type: string
required: true
concurrency:
# event_name in the key so the no-op pull_request run for a fork doesn't
# cancel the approval-pending pull_request_target run on the same PR.
group: preview-pr-${{ github.event.pull_request.number || inputs.pr_number }}-${{ github.event_name }}
cancel-in-progress: true
permissions:
contents: read
packages: write # GHCR image push
id-token: write # AWS OIDC federation for Pulumi
pull-requests: write # sticky PR comment with preview URL
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.r.outputs.pr_number }}
head_sha: ${{ steps.r.outputs.head_sha }}
proceed: ${{ steps.r.outputs.proceed }}
is_fork: ${{ steps.r.outputs.is_fork }}
steps:
- name: Resolve PR + route event
id: r
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_NAME: ${{ github.event_name }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EVENT_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
EVENT_BASE_REPO: ${{ github.repository }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
# Both pull_request and pull_request_target fire for every PR. We
# claim each event for exactly one path so the same PR doesn't try
# to deploy twice:
# pull_request → handle ONLY internal (same-repo) PRs
# pull_request_target → handle ONLY fork PRs (with approval gate)
# workflow_dispatch → handle either; forks still hit the gate
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
PR_NUM="$INPUT_PR_NUMBER"
HEAD_SHA=$(gh pr view "$PR_NUM" --repo "$EVENT_BASE_REPO" --json headRefOid -q .headRefOid)
HEAD_REPO=$(gh pr view "$PR_NUM" --repo "$EVENT_BASE_REPO" --json headRepository -q '.headRepository.nameWithOwner')
else
PR_NUM="$EVENT_PR_NUMBER"
HEAD_SHA="$EVENT_HEAD_SHA"
HEAD_REPO="$EVENT_HEAD_REPO"
fi
IS_FORK=false
if [ "$HEAD_REPO" != "$EVENT_BASE_REPO" ]; then
IS_FORK=true
fi
if [ "$EVENT_NAME" = "pull_request" ] && [ "$IS_FORK" = "true" ]; then
echo "proceed=false" >> $GITHUB_OUTPUT
echo "Fork PR — pull_request_target will handle this; exiting."
exit 0
fi
if [ "$EVENT_NAME" = "pull_request_target" ] && [ "$IS_FORK" = "false" ]; then
echo "proceed=false" >> $GITHUB_OUTPUT
echo "Internal PR — pull_request already handled this; exiting."
exit 0
fi
echo "pr_number=$PR_NUM" >> $GITHUB_OUTPUT
echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT
echo "is_fork=$IS_FORK" >> $GITHUB_OUTPUT
echo "proceed=true" >> $GITHUB_OUTPUT
echo "PR #$PR_NUM @ $HEAD_SHA (fork=$IS_FORK, event=$EVENT_NAME)"
approve-fork:
# Required-reviewer gate for fork PRs. Configure required reviewers on the
# `fork-preview-approval` environment in repo Settings → Environments.
# Each new push to a fork PR re-enters this gate (concurrency cancels the
# prior pending run), so approval is per-commit, not per-PR.
needs: resolve
if: needs.resolve.outputs.proceed == 'true' && needs.resolve.outputs.is_fork == 'true'
runs-on: ubuntu-latest
environment: fork-preview-approval
steps:
- run: |
echo "Fork preview approved for PR #${{ needs.resolve.outputs.pr_number }} @ ${{ needs.resolve.outputs.head_sha }}"
publish:
needs: [resolve, approve-fork]
# Runs when resolve told us to proceed AND either:
# - approve-fork ran and succeeded (fork path), OR
# - approve-fork was skipped (internal path; no gate needed)
if: |
always() &&
needs.resolve.outputs.proceed == 'true' &&
(needs.approve-fork.result == 'success' || needs.approve-fork.result == 'skipped')
uses: ./.github/workflows/images-publish.yml
with:
tag_override: pr-${{ needs.resolve.outputs.pr_number }}-${{ needs.resolve.outputs.head_sha }}
ref: ${{ needs.resolve.outputs.head_sha }}
vite_backend_url: https://api-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io/v1
deploy:
needs: [resolve, publish]
if: always() && needs.publish.result == 'success'
uses: ./.github/workflows/stack-deploy.yml
with:
action: deploy
platform: fargate
region: us-east-1
stack_name: preview-pr-${{ needs.resolve.outputs.pr_number }}
version: ${{ needs.publish.outputs.version }}
marketing_hostname: thunderbolt-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io
app_hostname: app-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io
api_hostname: api-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io
auth_hostname: auth-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io
powersync_hostname: powersync-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io
cloudflare_zone_id: ${{ vars.CLOUDFLARE_ZONE_ID }}
thunderbolt_inference_url: ${{ vars.THUNDERBOLT_INFERENCE_URL }}
# Per-PR stacks read VPC/ALB/postgres/keycloak/powersync from the long-lived
# `previews-shared` stack via Pulumi StackReference. The shared stack must
# exist before this runs; deploy it once via the `Previews Shared Deploy`
# workflow. See deploy/pulumi/SHARED.md.
shared_stack_name: previews-shared
secrets: inherit
comment:
needs: [resolve, deploy]
if: always() && needs.deploy.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Sticky PR comment with preview URLs
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: preview-env
number: ${{ needs.resolve.outputs.pr_number }}
message: |
**Preview environment deployed** 🚀
| Service | URL |
|---|---|
| Marketing / blog / docs | https://thunderbolt-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io |
| App | https://app-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io |
| API | https://api-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io |
| Keycloak | https://auth-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io |
| PowerSync | https://powersync-pr-${{ needs.resolve.outputs.pr_number }}.preview.thunderbolt.io |
Stack: `preview-pr-${{ needs.resolve.outputs.pr_number }}` · Commit: `${{ needs.resolve.outputs.head_sha }}`
Auto-destroys on PR close/merge. Login via the bundled Keycloak realm — `demo@thunderbolt.io` / `demo` by default.