-
Notifications
You must be signed in to change notification settings - Fork 321
186 lines (171 loc) · 8.49 KB
/
Copy pathpreview-deploy.yml
File metadata and controls
186 lines (171 loc) · 8.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
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.