Skip to content
316 linesCodeBlameRaw
1# Deploys g1t.sh from main, with g1t's own Actions. What it does is
2# scripts/deploy.mjs, the same tool a person runs; the guide is
3# docs.g1t.sh/guides/deploy-to-cloudflare/.
4#
5# check the deploy manifest is consistent, and the tool's tests pass
6# plan what changed since each Worker's live commit, and pending migrations
7# (beside check, not after it: neither waits for the other)
8# migrate pending D1 migrations, before any code, once check and plan pass
9# core, edge, front the units of each stage, in jobs that share a build;
10# a stage starts only when the one before it succeeded
11# smoke sign-in, sign-up and the waitlist still work on g1t.sh
12#
13# Each run that deploys is one production deployment of g1t.sh, made by the
14# jobs that name `environment: production` (one per run, however many jobs):
15# in progress when the first starts, then a success or a failure when the
16# run ends. It shows on the project's Deployments page and as the commit's
17# `deploy / production` check. The plan job reads production's secrets
18# with `deployment: false`, so a dry run or a change that deploys nothing
19# makes no deployment.
20#
21# Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row, with
22# Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and
23# api.cloudflare.com among the project's workflow-only domains for
24# deploy.yml in production (Settings, Guardrails), and
25# registry.cloudflare.com there too, to find, pull and push the runner's
26# image. A job that must build that image (the `runner-image` group) does
27# so with its own Docker Engine, on a larger machine. Optionally, the
28# secret STATUS_DEPLOY_TOKEN (the status Worker's secret of the same name)
29# and status.g1t.sh among the same workflow-only domains, so status.g1t.sh
30# hears each deploy start and finish.
31name: Deploy
32
33on:
34 push:
35 branches: [main]
36 workflow_dispatch:
37 inputs:
38 units:
39 description: "Units to deploy whether or not they changed, comma separated (empty: what changed)"
40 type: string
41 default: ""
42 all:
43 description: "Deploy every unit"
44 type: boolean
45 default: false
46 dry_run:
47 description: "Plan only: deploy nothing"
48 type: boolean
49 default: false
50
51# Its token only reads: deploying uses CLOUDFLARE_API_TOKEN, and g1t
52# records the deployments itself.
53permissions:
54 contents: read
55
56# One deploy at a time, and never one cut off halfway: the next waits.
57concurrency:
58 group: deploy-production
59 cancel-in-progress: false
60
61env:
62 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
63 CARGO_TERM_COLOR: never
64 WRANGLER_SEND_METRICS: "false"
65
66jobs:
67 check:
68 name: Check
69 runs-on: ubuntu-latest
70 timeout-minutes: 20
71 steps:
72 - uses: actions/checkout@v5
73 # npm's downloads, kept between runs: an install reads them first
74 # and asks the registry only for what changed, so a dropped
75 # connection to it rarely stops the job.
76 - name: Cache npm's downloads
77 uses: actions/cache@v4
78 with:
79 path: ~/.npm
80 key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
81 restore-keys: npm-${{ runner.os }}-
82 - name: Install Wrangler
83 run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
84 - name: The manifest matches every wrangler.jsonc
85 run: node scripts/deploy.mjs manifest --check
86 - name: The deploy tool's tests
87 run: npm run test:deploy
88
89 # Runs beside check: nothing deploys until both have succeeded.
90 plan:
91 name: Plan
92 runs-on: ubuntu-latest
93 # Production's secrets, without a deployment: planning deploys nothing.
94 environment:
95 name: production
96 deployment: false
97 timeout-minutes: 15
98 outputs:
99 migrate: ${{ steps.plan.outputs.migrate }}
100 migrate_units: ${{ steps.plan.outputs.migrate_units }}
101 has_core: ${{ steps.plan.outputs.has_core }}
102 core: ${{ steps.plan.outputs.core }}
103 has_edge: ${{ steps.plan.outputs.has_edge }}
104 edge: ${{ steps.plan.outputs.edge }}
105 has_front: ${{ steps.plan.outputs.has_front }}
106 front: ${{ steps.plan.outputs.front }}
107 steps:
108 - uses: actions/checkout@v5
109 with:
110 # Each Worker's live commit is compared with this one.
111 fetch-depth: 0
112 # No npm ci: with CLOUDFLARE_API_TOKEN the plan reads Cloudflare's API
113 # itself (scripts/deploy/cloudflare.mjs), and needs no Wrangler.
114 - name: Plan
115 id: plan
116 env:
117 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
118 UNITS: ${{ inputs.units }}
119 ALL: ${{ inputs.all }}
120 run: |
121 args=()
122 if [ -n "$UNITS" ]; then args+=(--only "$UNITS" --force); fi
123 if [ "$ALL" = "true" ]; then args+=(--all); fi
124 node scripts/deploy.mjs plan "${args[@]}" --github-output
125
126 migrate:
127 name: Migrations
128 needs: [check, plan]
129 if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }}
130 runs-on: ubuntu-latest
131 environment:
132 name: production
133 url: https://g1t.sh
134 timeout-minutes: 20
135 steps:
136 - uses: actions/checkout@v5
137 # npm's downloads, kept between runs: an install reads them first
138 # and asks the registry only for what changed, so a dropped
139 # connection to it rarely stops the job.
140 - name: Cache npm's downloads
141 uses: actions/cache@v4
142 with:
143 path: ~/.npm
144 key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
145 restore-keys: npm-${{ runner.os }}-
146 - name: Install Wrangler
147 run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
148 - name: Apply pending migrations
149 env:
150 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
151 run: node scripts/deploy.mjs migrate --only "${{ needs.plan.outputs.migrate_units }}"
152
153 core:
154 name: core (${{ matrix.group }})
155 needs: [check, plan, migrate]
156 # Runs when nothing before it failed: a migrate job skipped for having
157 # nothing to apply is not a failure.
158 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }}
159 # Rust builds and the runner's image get 4 vCPUs; everything else the
160 # standard machine.
161 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
162 environment:
163 name: production
164 url: https://g1t.sh
165 timeout-minutes: 60
166 strategy:
167 # A deploy cut off halfway is worse than one that finishes: the other
168 # jobs of a stage run on when one fails, and the next stage does not.
169 fail-fast: false
170 max-parallel: 4
171 matrix: ${{ fromJSON(needs.plan.outputs.core) }}
172 steps: &deploy
173 - uses: actions/checkout@v5
174 with:
175 fetch-depth: 0
176 # Rust workers: the wasm target, and worker-build kept between runs
177 # (its version is pinned in scripts/build-rust-worker.mjs).
178 - name: Rust for Workers
179 if: ${{ matrix.rust }}
180 run: rustup target add wasm32-unknown-unknown
181 - name: Cache worker-build
182 if: ${{ matrix.rust }}
183 uses: actions/cache@v4
184 with:
185 path: ~/.cargo/bin/worker-build
186 key: worker-build-${{ runner.os }}-${{ hashFiles('scripts/build-rust-worker.mjs') }}
187 - name: Cache worker-build's tools (wasm-bindgen, esbuild)
188 if: ${{ matrix.rust }}
189 uses: actions/cache@v4
190 with:
191 path: ~/.cache/worker-build
192 key: worker-build-tools-${{ runner.os }}-${{ hashFiles('scripts/build-rust-worker.mjs') }}
193 - name: Cache crates
194 if: ${{ matrix.rust }}
195 uses: actions/cache@v4
196 with:
197 path: ~/.cargo/registry/cache
198 key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
199 restore-keys: cargo-crates-${{ runner.os }}-
200 # The compiled dependencies of this job's units, for wasm32 and the
201 # build scripts and proc macros they run. Cargo calls rustc again for
202 # the workspace's own crates whatever is cached (a checkout's sources
203 # are newer); sccache, below, answers those calls. So an entry is
204 # saved only when the dependencies change: a new Cargo.lock, or a new
205 # base image (base.json names its Rust). Otherwise the nearest earlier
206 # entry, of any group, is a start.
207 - name: Cache the Cargo target
208 if: ${{ matrix.rust }}
209 uses: actions/cache@v4
210 with:
211 path: |
212 target/release
213 target/wasm32-unknown-unknown/release
214 !target/**/incremental
215 !target/**/*.wasm
216 key: cargo-target-${{ runner.os }}-${{ matrix.group }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
217 restore-keys: |
218 cargo-target-${{ runner.os }}-${{ matrix.group }}-
219 cargo-target-${{ runner.os }}-
220 # The runner's image: its binary, built natively for musl (the base
221 # has musl-gcc; the target is added here), with its Cargo target kept
222 # between runs. The image itself is built and pushed with the job's
223 # own Docker Engine (scripts/deploy/image.mjs).
224 - name: Rust for the runner
225 if: ${{ matrix.image }}
226 run: rustup target add x86_64-unknown-linux-musl
227 - name: Cache the runner's build
228 if: ${{ matrix.image }}
229 uses: actions/cache@v4
230 with:
231 path: |
232 ~/.cargo/registry/cache
233 target/x86_64-unknown-linux-musl/release
234 !target/**/incremental
235 key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
236 restore-keys: runner-musl-${{ runner.os }}-
237 # Every rustc call that makes a library (the workspace's crates,
238 # which Cargo compiles again on every checkout, and any dependency
239 # not restored above) is looked up by its inputs in the repository's
240 # Actions cache: unchanged crates come back from it instead of being
241 # compiled. A Worker's own crate (a cdylib) and the runner's binary
242 # are still compiled. worker-build runs Cargo, so its wasm32 builds
243 # go through it too; wasm-bindgen and wasm-opt are not rustc.
244 # Pinned by version and sha256 in scripts/sccache.sh; without the
245 # cache, the job builds as before.
246 - name: sccache
247 if: ${{ matrix.rust || matrix.image }}
248 run: bash scripts/sccache.sh install
249 # npm's downloads, kept between runs as the check and migrate jobs
250 # keep them: the install reads them first (--prefer-offline, in
251 # scripts/deploy/stack.mjs) and asks the registry only for what
252 # changed, so a connection it drops mid-install rarely stops a stage.
253 - name: Cache npm's downloads
254 uses: actions/cache@v4
255 with:
256 path: ~/.npm
257 key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
258 restore-keys: npm-${{ runner.os }}-
259 - name: Install
260 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
261 - name: Deploy ${{ matrix.units }}
262 env:
263 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
264 # status.g1t.sh hears the deploy start and finish, so its restarts
265 # are not drafted as incidents. Optional: without it, nothing is sent.
266 STATUS_DEPLOY_TOKEN: ${{ secrets.STATUS_DEPLOY_TOKEN }}
267 run: node scripts/deploy.mjs deploy --only "${{ matrix.units }}" --force --no-migrations --concurrency 2
268 # Hits and misses, on the log and in the run's summary.
269 - name: sccache's hits and misses
270 if: ${{ always() && (matrix.rust || matrix.image) }}
271 run: bash scripts/sccache.sh stats
272
273 edge:
274 name: edge (${{ matrix.group }})
275 needs: [check, plan, migrate, core]
276 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }}
277 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
278 environment:
279 name: production
280 url: https://g1t.sh
281 timeout-minutes: 60
282 strategy:
283 fail-fast: false
284 max-parallel: 4
285 matrix: ${{ fromJSON(needs.plan.outputs.edge) }}
286 steps: *deploy
287
288 front:
289 name: front (${{ matrix.group }})
290 needs: [check, plan, migrate, core, edge]
291 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }}
292 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
293 environment:
294 name: production
295 url: https://g1t.sh
296 timeout-minutes: 60
297 strategy:
298 fail-fast: false
299 max-parallel: 4
300 matrix: ${{ fromJSON(needs.plan.outputs.front) }}
301 steps: *deploy
302
303 # Once everything has deployed: the ways in for someone new still work.
304 # The pages a visitor lands on load, and the waitlist form reaches
305 # identity, sent an address it refuses before keeping anything, so the
306 # real waitlist is never touched. scripts/ops/smoke.mjs.
307 smoke:
308 name: Smoke
309 needs: [check, plan, core, edge, front]
310 if: ${{ !failure() && !cancelled() && inputs.dry_run != true && (needs.plan.outputs.has_core == 'true' || needs.plan.outputs.has_edge == 'true' || needs.plan.outputs.has_front == 'true') }}
311 runs-on: ubuntu-latest
312 timeout-minutes: 5
313 steps:
314 - uses: actions/checkout@v5
315 - name: Sign-in, sign-up and the waitlist work
316 run: node scripts/ops/smoke.mjs