-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
181 lines (172 loc) · 8.64 KB
/
Copy pathdeploy.yml
File metadata and controls
181 lines (172 loc) · 8.64 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
name: Deploy
# Called by ci.yml once the build succeeds. One job, in one order: preflight,
# migrations, then the three workers.
#
# Nothing here is atomic. The order narrows each window, but a failure, timeout
# or lost runner can still stop the release midway, so every step has to be
# survivable alone – hence the expand/contract rules below.
on:
workflow_call:
inputs:
environment:
required: true
type: string
url:
description: "URL of the deployment"
required: true
type: string
# A caller's workflow-level `env` does not reach a reusable workflow, so this is
# repeated rather than inherited from ci.yml. Runners have no use for Git hooks.
env:
HUSKY: 0
jobs:
deploy:
name: ${{ inputs.environment }}
runs-on: ubuntu-latest
# Generous on purpose: a timeout firing partway through this job is itself a
# way into the mixed state everything here avoids. It bounds a runaway, it
# is not a latency target.
timeout-minutes: 45
# No `deployments: write` – naming `environment:` is what records the
# deployment, and GitHub creates it. The scope only governs code calling the
# deployments API, which nothing here does.
permissions:
contents: read # for actions/checkout
# Naming the environment is what grants the credentials below. They are
# environment secrets, never repository ones: a protection rule only gates
# jobs that name the environment, so scoping them there is what stops an
# edited workflow from reaching them.
environment:
name: ${{ inputs.environment }}
url: ${{ inputs.url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# This job holds deployment credentials; it has no use for a Git one.
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
# Restored after `bun install`, whose lifecycle scripts could otherwise
# overwrite an already-verified `dist`.
#
# `upload-artifact` roots the archive at the least common ancestor of its
# paths – `apps/` here – so restore it there, not at the workspace root,
# or each `dist` lands a level too high and Wrangler resolves `./dist`
# relative to apps/*/wrangler.jsonc to nothing.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build
path: apps
# `apps/email/dist` is not deployed directly – the API worker imports
# `@repo/email`, whose exports point there, so `worker.ts` fails to bundle
# without it.
#
# `scripts/deploy.ts` checks the same three, and this stays anyway: that
# check runs after the migration, and a restore that silently dropped a
# `dist` should fail before the point of no return, not after it.
- name: Verify build artifacts
run: |
test -d apps/email/dist
test -d apps/web/dist
test -d apps/app/dist
# Everything checkable before the first mutation, checked here: the
# migration below is the point of no return, and a missing credential or a
# worker that does not bundle should not surface after the schema moved.
#
# Three steps rather than one because each needs strictly less than the
# last, and `env:` is what enforces it. As one step, every Wrangler
# process would inherit the database URL for no reason.
- name: Preflight – credentials
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DEPLOY_ENV: ${{ inputs.environment }}
run: |
# The account ID is a repository variable by default, and only moves
# to the environment when staging and production use different
# Cloudflare accounts – hence the separate message.
if [[ -z "$CLOUDFLARE_ACCOUNT_ID" ]]; then
echo "::error::CLOUDFLARE_ACCOUNT_ID is empty. Set it as a repository variable, or as a variable on the ${DEPLOY_ENV} environment if each environment uses its own Cloudflare account."
exit 1
fi
for name in CLOUDFLARE_API_TOKEN DATABASE_URL; do
if [[ -z "${!name}" ]]; then
echo "::error::${name} is empty. Set it as a secret on the ${DEPLOY_ENV} environment."
exit 1
fi
done
# Authentication, not authorisation: proves the token is valid and belongs
# to the target account, and `--json` is what makes an unauthenticated
# call exit non-zero rather than print a friendly message and pass. It
# cannot prove the token carries Workers Scripts Write – Cloudflare's
# **Edit Cloudflare Workers** template does.
- name: Preflight – Cloudflare authentication
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: bun wrangler whoami --account "$CLOUDFLARE_ACCOUNT_ID" --json > /dev/null
# `--dry-run` compiles each worker against its target environment without
# uploading or authenticating – hence no credentials in this step.
- name: Preflight – worker bundles
env:
DEPLOY_ENV: ${{ inputs.environment }}
run: |
# Production is the top-level config, selected with an empty
# environment; staging is named. Wrangler rejects `--env production`.
if [[ "$DEPLOY_ENV" == "production" ]]; then
env_args=(--env "")
else
env_args=(--env "$DEPLOY_ENV")
fi
for app in api app web; do
bun wrangler deploy --config "apps/${app}/wrangler.jsonc" "${env_args[@]}" --dry-run
done
# Migrations run before the workers, so new code never meets an old
# schema. The old workers keep serving against the migrated database until
# the next step replaces them, and a rollback restores workers without
# reverting the schema – so expand first (add, backfill, read both shapes)
# and contract in a later release.
#
# Use the unpooled connection string: migrations take DDL locks, and
# Hyperdrive is for the request path.
- name: Run database migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DEPLOY_ENV: ${{ inputs.environment }}
run: |
# `drizzle.config.ts` fails closed for staging and production: it reads
# `.env.<environment>.local` and refuses a `DATABASE_URL` inherited
# from the shell, so a stale exported value cannot migrate the wrong
# database. The file exists for one command – unreadable to anyone
# else, removed however the step exits – and `unset` leaves it as the
# only place the migration can read the credential from.
env_file=".env.${DEPLOY_ENV}.local"
trap 'rm -f "$env_file"' EXIT
umask 077
printf 'DATABASE_URL=%s\n' "$DATABASE_URL" > "$env_file"
unset DATABASE_URL
bun run "db:migrate:${DEPLOY_ENV}"
# Order matters: a service binding resolves its target by name at deploy
# time, so `api` and `app` must exist before `web` binds to them. `web`
# also holds the only public route, so flipping it last moves user traffic
# after the workers behind it are new.
#
# That narrows the skew but cannot remove it, and does nothing for the
# browser – every user still running the SPA they loaded an hour ago keeps
# calling the new API. So API responses evolve like the schema: add the
# field, ship the clients that read it, drop the old shape later.
- name: Deploy workers
env:
# Set explicitly: a token with access to more than one account cannot
# infer which.
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
DEPLOY_ENV: ${{ inputs.environment }}
# `bun deploy:{staging,production}` runs the same script locally, so a
# release from a laptop and one from CI cannot drift in order or in
# environment selection. It maps production to Wrangler's empty `--env`
# itself and rejects any other environment name outright, so a value
# lost in transit fails the run rather than deploying production.
# `--skip-build` because the restored artifact is the verified build.
run: bun scripts/deploy.ts "$DEPLOY_ENV" --skip-build