Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
378 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 and sign-up 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 the runner's image and make
26# it. A job that must make that image (the `runner-image` group) builds the
27# runner's binary on a larger machine and makes the image in the registry
28# with no Docker: one layer with the binary on the base's layers, pulling
29# none of the base (scripts/deploy/registry.mjs). Optionally, the
30# secret STATUS_DEPLOY_TOKEN (the status Worker's secret of the same name)
31# and status.g1t.sh among the same workflow-only domains, so status.g1t.sh
32# hears each deploy start and finish.
33name: Deploy
34
35on:
36 push:
37 branches: [main]
38 workflow_dispatch:
39 inputs:
40 units:
41 description: "Units to deploy whether or not they changed, comma separated (empty: what changed)"
42 type: string
43 default: ""
44 all:
45 description: "Deploy every unit"
46 type: boolean
47 default: false
48 dry_run:
49 description: "Plan only: deploy nothing"
50 type: boolean
51 default: false
52
53# Its token only reads: deploying uses CLOUDFLARE_API_TOKEN, and g1t
54# records the deployments itself.
55permissions:
56 contents: read
57
58# One deploy at a time, and never one cut off halfway: the next waits.
59concurrency:
60 group: deploy-production
61 cancel-in-progress: false
62
63env:
64 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
65 CARGO_TERM_COLOR: never
66 WRANGLER_SEND_METRICS: "false"
67
68jobs:
69 check:
70 name: Check
71 runs-on: ubuntu-latest
72 timeout-minutes: 20
73 steps:
74 - uses: actions/checkout@v5
75 # npm's downloads for the whole lockfile, which the npm-cache job
76 # keeps: an install reads them first and asks the registry only for
77 # what changed, so a dropped connection to it rarely stops the job.
78 # Only restored: a job that installs part of the lockfile never
79 # saves, or its part would stand in for the whole under that key.
80 - name: Cache npm's downloads
81 uses: actions/cache/restore@v4
82 with:
83 path: ~/.npm
84 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
85 restore-keys: npm-full-${{ runner.os }}-
86 - name: Install Wrangler
87 run: node scripts/deploy/npm-ci.mjs --prefer-offline --workspaces=false --no-audit --no-fund
88 - name: The manifest matches every wrangler.jsonc
89 run: node scripts/deploy.mjs manifest --check
90 - name: The deploy tool's tests
91 run: npm run test:deploy
92
93 # npm's downloads for the whole lockfile, saved once for each lockfile
94 # on main, which every install of this and the other workflows starts
95 # from. Before, every job saved its own part under one key, and the
96 # first to finish (Wrangler alone, 73 MB) stood in for the rest: each
97 # stage then downloaded hundreds of MB through the runner's egress,
98 # 50 MB tarballs among them. Nothing waits for this job, and a failed
99 # download only leaves the cache as it was.
100 npm-cache:
101 name: npm's downloads
102 runs-on: ubuntu-latest
103 timeout-minutes: 20
104 steps:
105 - uses: actions/checkout@v5
106 - name: Look for this lockfile's downloads
107 id: saved
108 uses: actions/cache/restore@v4
109 with:
110 path: ~/.npm
111 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
112 lookup-only: true
113 - name: Start from the last lockfile's
114 if: ${{ steps.saved.outputs.cache-hit != 'true' }}
115 uses: actions/cache/restore@v4
116 with:
117 path: ~/.npm
118 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
119 restore-keys: npm-full-${{ runner.os }}-
120 - name: Download every package
121 id: download
122 if: ${{ steps.saved.outputs.cache-hit != 'true' }}
123 continue-on-error: true
124 run: node scripts/deploy/npm-ci.mjs --prefer-offline --ignore-scripts --no-audit --no-fund
125 - name: Save them
126 if: ${{ steps.saved.outputs.cache-hit != 'true' && steps.download.outcome == 'success' }}
127 uses: actions/cache/save@v4
128 with:
129 path: ~/.npm
130 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
131
132 # Runs beside check: nothing deploys until both have succeeded.
133 plan:
134 name: Plan
135 runs-on: ubuntu-latest
136 # Production's secrets, without a deployment: planning deploys nothing.
137 environment:
138 name: production
139 deployment: false
140 timeout-minutes: 15
141 outputs:
142 migrate: ${{ steps.plan.outputs.migrate }}
143 migrate_units: ${{ steps.plan.outputs.migrate_units }}
144 has_core: ${{ steps.plan.outputs.has_core }}
145 core: ${{ steps.plan.outputs.core }}
146 has_edge: ${{ steps.plan.outputs.has_edge }}
147 edge: ${{ steps.plan.outputs.edge }}
148 has_front: ${{ steps.plan.outputs.has_front }}
149 front: ${{ steps.plan.outputs.front }}
150 steps:
151 - uses: actions/checkout@v5
152 with:
153 # Each Worker's live commit is compared with this one.
154 fetch-depth: 0
155 # No npm ci: with CLOUDFLARE_API_TOKEN the plan reads Cloudflare's API
156 # itself (scripts/deploy/cloudflare.mjs), and needs no Wrangler.
157 - name: Plan
158 id: plan
159 env:
160 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
161 UNITS: ${{ inputs.units }}
162 ALL: ${{ inputs.all }}
163 run: |
164 args=()
165 if [ -n "$UNITS" ]; then args+=(--only "$UNITS" --force); fi
166 if [ "$ALL" = "true" ]; then args+=(--all); fi
167 node scripts/deploy.mjs plan "${args[@]}" --github-output
168
169 migrate:
170 name: Migrations
171 needs: [check, plan]
172 if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }}
173 runs-on: ubuntu-latest
174 environment:
175 name: production
176 url: https://g1t.sh
177 timeout-minutes: 20
178 steps:
179 - uses: actions/checkout@v5
180 # npm's downloads, kept between runs: an install reads them first
181 # and asks the registry only for what changed, so a dropped
182 # connection to it rarely stops the job.
183 - name: Cache npm's downloads
184 uses: actions/cache/restore@v4
185 with:
186 path: ~/.npm
187 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
188 restore-keys: npm-full-${{ runner.os }}-
189 - name: Install Wrangler
190 run: node scripts/deploy/npm-ci.mjs --prefer-offline --workspaces=false --no-audit --no-fund
191 - name: Apply pending migrations
192 env:
193 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
194 run: node scripts/deploy.mjs migrate --only "${{ needs.plan.outputs.migrate_units }}"
195
196 core:
197 name: core (${{ matrix.group }})
198 needs: [check, plan, migrate]
199 # Runs when nothing before it failed: a migrate job skipped for having
200 # nothing to apply is not a failure.
201 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }}
202 # Rust builds and the runner's image get 4 vCPUs; everything else the
203 # standard machine.
204 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
205 environment:
206 name: production
207 url: https://g1t.sh
208 timeout-minutes: 60
209 strategy:
210 # A deploy cut off halfway is worse than one that finishes: the other
211 # jobs of a stage run on when one fails, and the next stage does not.
212 fail-fast: false
213 max-parallel: 4
214 matrix: ${{ fromJSON(needs.plan.outputs.core) }}
215 steps: &deploy
216 - uses: actions/checkout@v5
217 with:
218 fetch-depth: 0
219 # Rust workers: the wasm target, and worker-build kept between runs
220 # (its version is pinned in scripts/build-rust-worker.mjs).
221 - name: Rust for Workers
222 if: ${{ matrix.rust }}
223 run: rustup target add wasm32-unknown-unknown
224 - name: Cache worker-build
225 if: ${{ matrix.rust }}
226 uses: actions/cache@v4
227 with:
228 path: ~/.cargo/bin/worker-build
229 key: worker-build-${{ runner.os }}-${{ hashFiles('scripts/build-rust-worker.mjs') }}
230 - name: Cache worker-build's tools (wasm-bindgen, esbuild)
231 if: ${{ matrix.rust }}
232 uses: actions/cache@v4
233 with:
234 path: ~/.cache/worker-build
235 key: worker-build-tools-${{ runner.os }}-${{ hashFiles('scripts/build-rust-worker.mjs') }}
236 - name: Cache crates
237 if: ${{ matrix.rust }}
238 uses: actions/cache@v4
239 with:
240 path: ~/.cargo/registry/cache
241 key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
242 restore-keys: cargo-crates-${{ runner.os }}-
243 # The compiled dependencies of this job's units, for wasm32 and the
244 # build scripts and proc macros they run. Cargo calls rustc again for
245 # the workspace's own crates whatever is cached (a checkout's sources
246 # are newer); sccache, below, answers those calls. So an entry is
247 # saved only when the dependencies change: a new Cargo.lock, or a new
248 # base image (base.json names its Rust). Otherwise the nearest earlier
249 # entry, of any group, is a start.
250 - name: Cache the Cargo target
251 if: ${{ matrix.rust }}
252 uses: actions/cache@v4
253 with:
254 path: |
255 target/release
256 target/wasm32-unknown-unknown/release
257 !target/**/incremental
258 !target/**/*.wasm
259 key: cargo-target-${{ runner.os }}-${{ matrix.group }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
260 restore-keys: |
261 cargo-target-${{ runner.os }}-${{ matrix.group }}-
262 cargo-target-${{ runner.os }}-
263 # The runner's image: its binary, built natively for musl (the base
264 # has musl-gcc; the target is added here), with its Cargo target kept
265 # between runs. The image itself is made in the registry, without
266 # Docker: the base's layers and one with the binary
267 # (scripts/deploy/registry.mjs). Building it with the job's Docker
268 # Engine, only if that fails, pulls the 4.5 GB base onto the job's
269 # disk.
270 - name: Rust for the runner
271 if: ${{ matrix.image }}
272 run: rustup target add x86_64-unknown-linux-musl
273 - name: Cache the runner's build
274 if: ${{ matrix.image }}
275 uses: actions/cache@v4
276 with:
277 path: |
278 ~/.cargo/registry/cache
279 target/x86_64-unknown-linux-musl/release
280 !target/**/incremental
281 key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
282 restore-keys: runner-musl-${{ runner.os }}-
283 # Every rustc call that makes a library (the workspace's crates,
284 # which Cargo compiles again on every checkout, and any dependency
285 # not restored above) is looked up by its inputs in the repository's
286 # Actions cache: unchanged crates come back from it instead of being
287 # compiled. A Worker's own crate (a cdylib) and the runner's binary
288 # are still compiled. worker-build runs Cargo, so its wasm32 builds
289 # go through it too; wasm-bindgen and wasm-opt are not rustc.
290 # Pinned by version and sha256 in scripts/sccache.sh; without the
291 # cache, the job builds as before.
292 - name: sccache
293 if: ${{ matrix.rust || matrix.image }}
294 run: bash scripts/sccache.sh install
295 # npm's downloads for the whole lockfile, only restored, as the
296 # check job restores them: the install reads them first (--prefer-offline, in
297 # scripts/deploy/stack.mjs) and asks the registry only for what
298 # changed, so a connection it drops mid-install rarely stops a stage.
299 - name: Cache npm's downloads
300 uses: actions/cache/restore@v4
301 with:
302 path: ~/.npm
303 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
304 restore-keys: npm-full-${{ runner.os }}-
305 - name: Install
306 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
307 # The runner's image job: npm's downloads are only restored here, and
308 # the install has read them, so they go, leaving the disk to the
309 # runner's build (and to Docker, should the image need it). What the
310 # disk holds is logged before and after, so a job that runs out of
311 # room says what took it.
312 - name: Room for the runner's image
313 if: ${{ matrix.image }}
314 run: |
315 rm -rf ~/.npm
316 df -h / "$HOME" /tmp
317 du -sh "$HOME"/.cargo target node_modules 2>/dev/null || true
318 - name: Deploy ${{ matrix.units }}
319 env:
320 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
321 # status.g1t.sh hears the deploy start and finish, so its restarts
322 # are not drafted as incidents. Optional: without it, nothing is sent.
323 STATUS_DEPLOY_TOKEN: ${{ secrets.STATUS_DEPLOY_TOKEN }}
324 run: node scripts/deploy.mjs deploy --only "${{ matrix.units }}" --force --no-migrations --concurrency 2
325 - name: The disk after the runner's image
326 if: ${{ always() && matrix.image }}
327 run: |
328 df -h / "$HOME" /tmp
329 du -sh "$HOME"/.cargo target node_modules 2>/dev/null || true
330 # Hits and misses, on the log and in the run's summary.
331 - name: sccache's hits and misses
332 if: ${{ always() && (matrix.rust || matrix.image) }}
333 run: bash scripts/sccache.sh stats
334
335 edge:
336 name: edge (${{ matrix.group }})
337 needs: [check, plan, migrate, core]
338 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }}
339 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
340 environment:
341 name: production
342 url: https://g1t.sh
343 timeout-minutes: 60
344 strategy:
345 fail-fast: false
346 max-parallel: 4
347 matrix: ${{ fromJSON(needs.plan.outputs.edge) }}
348 steps: *deploy
349
350 front:
351 name: front (${{ matrix.group }})
352 needs: [check, plan, migrate, core, edge]
353 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }}
354 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
355 environment:
356 name: production
357 url: https://g1t.sh
358 timeout-minutes: 60
359 strategy:
360 fail-fast: false
361 max-parallel: 4
362 matrix: ${{ fromJSON(needs.plan.outputs.front) }}
363 steps: *deploy
364
365 # Once everything has deployed: the ways in for someone new still work.
366 # The pages a visitor lands on load, and the sign-up form reaches
367 # identity, sent an address it refuses before keeping or counting
368 # anything, so no account is made. scripts/ops/smoke.mjs.
369 smoke:
370 name: Smoke
371 needs: [check, plan, core, edge, front]
372 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') }}
373 runs-on: ubuntu-latest
374 timeout-minutes: 5
375 steps:
376 - uses: actions/checkout@v5
377 - name: Sign-in and sign-up work
378 run: node scripts/ops/smoke.mjs