Repository navigation
228 lines (217 loc) · 11.4 KB
/
Copy pathrelease.yml
File metadata and controls
228 lines (217 loc) · 11.4 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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
name: Release images
on:
push:
tags:
- "v*"
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
jobs:
images:
name: Build and push images
runs-on: ubuntu-latest
outputs:
box-digest: ${{ steps.box.outputs.digest }}
image-owner: ${{ steps.namespace.outputs.owner }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# GHCR image names must be lowercase; the repository owner may not be.
# Computed once and published as a job output, so the release notes name
# the same images this job actually pushed.
- name: Compute lowercase image namespace
id: namespace
env:
OWNER: ${{ github.repository_owner }}
run: |
owner="$(printf '%s' "$OWNER" | tr '[:upper:]' '[:lower:]')"
echo "IMAGE_OWNER=$owner" >> "$GITHUB_ENV"
echo "owner=$owner" >> "$GITHUB_OUTPUT"
- name: Plan the baked payload version
id: payload
run: echo "version=$(node packages/control-plane/scripts/plan-box-payload.mjs --print-version)" >> "$GITHUB_OUTPUT"
- name: Build and push box
id: box
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
# Client prod leaves BOX_LODY_SESSIONS unset here; it turns Lody on
# when it pins a payload deployment.
with:
context: .
file: packages/box/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
provenance: true
build-args: |
BLITZ_PAYLOAD_VERSION=${{ steps.payload.outputs.version }}
tags: |
ghcr.io/${{ env.IMAGE_OWNER }}/blitz-box:${{ github.ref_name }}
ghcr.io/${{ env.IMAGE_OWNER }}/blitz-box:latest
# Deploys the control plane pinned to the box image this run just pushed, so
# a version tag is a complete release with no manual publish step. Opt-in via
# two repository secrets (docs/BOX-IMAGE.md § Automatic releases); without
# them the job skips with a notice, so forks and CI-only checkouts are
# unaffected. Secrets stay in Cloudflare: the deploy only checks their
# presence with `wrangler secret list`.
deploy-control-plane:
name: Deploy control plane with the released box image
needs: images
runs-on: ubuntu-latest
# Client prod. Add a required reviewer to this environment in repository
# settings and the tag still triggers the release, but a person approves
# the deploy. The images above are already built and pushed by then, so an
# approval that never comes costs nothing but an unused image tag.
environment: production
steps:
- name: Check for deploy configuration
id: gate
env:
PROD_WRANGLER_TOML: ${{ secrets.PROD_WRANGLER_TOML }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -z "$PROD_WRANGLER_TOML" ] || [ -z "$CLOUDFLARE_API_TOKEN" ]; then
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::notice::Control-plane deploy skipped: set the PROD_WRANGLER_TOML and CLOUDFLARE_API_TOKEN repository secrets to deploy on tag pushes (docs/BOX-IMAGE.md)."
else
echo "configured=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
if: steps.gate.outputs.configured == 'true'
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: steps.gate.outputs.configured == 'true'
with:
node-version-file: package.json
cache: npm
- run: npm ci
if: steps.gate.outputs.configured == 'true'
# wrangler's config patcher rejects a TOML that holds comments, so this
# secret must be comment-free — the deploy writes to it on every run.
# It does not have to be up to date: the deploy fills in any key
# wrangler.toml.example has gained, and generates assets.run_worker_first
# from core's route registrations. Only account-specific identifiers
# (account_id, database_id, zone ids, APP_URL) have to be set here.
- name: Write the deployment wrangler.toml
if: steps.gate.outputs.configured == 'true'
env:
PROD_WRANGLER_TOML: ${{ secrets.PROD_WRANGLER_TOML }}
run: printf '%s\n' "$PROD_WRANGLER_TOML" > packages/control-plane/wrangler.toml
- name: Pin the released box image
if: steps.gate.outputs.configured == 'true'
env:
IMAGE_OWNER: ${{ needs.images.outputs.image-owner }}
BOX_DIGEST: ${{ needs.images.outputs.box-digest }}
run: node packages/control-plane/scripts/set-box-image-ref.mjs --ref "ghcr.io/$IMAGE_OWNER/blitz-box@$BOX_DIGEST"
# VM bootstraps pull with no registry login, and GHCR packages are
# born private, so deploying an unpullable digest would break every new
# workspace. Failing here leaves the deployment on its previous image —
# flip the package public (one-time) and re-run this job.
- name: Verify the image is publicly pullable
if: steps.gate.outputs.configured == 'true'
env:
IMAGE_OWNER: ${{ needs.images.outputs.image-owner }}
BOX_DIGEST: ${{ needs.images.outputs.box-digest }}
run: |
token="$(curl -fsS "https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_OWNER}/blitz-box:pull" | jq -r '.token // empty')"
code="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $token" \
-H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
"https://ghcr.io/v2/${IMAGE_OWNER}/blitz-box/manifests/${BOX_DIGEST}")"
if [ "$code" != "200" ]; then
echo "::error::ghcr.io/${IMAGE_OWNER}/blitz-box is not publicly pullable (manifest HTTP $code). Make the package public (Package settings → Danger zone → Change visibility), then re-run this job. Not deploying an image workspaces cannot pull."
exit 1
fi
- name: Deploy
if: steps.gate.outputs.configured == 'true'
# Both hosted deployments require every org to bring its own cloud
# credential. A self-hoster runs `npm run deploy` without this and
# keeps the example default, deployment-fallback.
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
BLITZ_DEPLOY_VAR_CLOUD_WORKSPACE_CREDENTIAL_POLICY: byok-required
# The golden Hetzner snapshot a workspace boots. It removes the apt
# install, the box-image download and the sshd move from first boot:
# 130.6 s to 45.3 s, measured 2026-08-27.
#
# `*` is every location, not a shortcut. A Hetzner snapshot carries an
# architecture and no location at all, so one x86 image boots every
# x86 type in the project. Measured 2026-08-28: cx23@hel1 reaches its
# relocated sshd in 41.3 s and cpx21@hil in 40.1 s, from this image.
# An arm type would not match and would fall back to stock Ubuntu with
# a warning; the catalog offers none today.
#
# The SAME id as canary, because both deployments use one Hetzner
# project. A snapshot cannot cross projects, so that shared project
# is the only reason one value serves both. Verified 2026-08-28: of
# the 8 labelled servers in it, canary's database claims 1.
#
# Only orgs with org_entitlements.platform_compute = 1 ever reach it,
# because this deployment is byok-required and the image is wired to
# the deployment credential alone. Everyone else boots stock Ubuntu.
#
# Rebake with `npm run golden:bake -- --location hel1` and update
# BOTH workflows. An id that no longer resolves is not an outage: the
# adapter warns and falls back to stock Ubuntu.
BLITZ_DEPLOY_VAR_HETZNER_SERVER_IMAGES: '*=425047509'
# The billing service's origin, once it has one. Empty is skipped, so
# this line changes nothing until the value is set, and setting it
# then needs no commit. It is an environment secret rather than an
# environment variable, which costs only the masking of a public URL
# in the log, and keeps every per-deployment setting in one place.
BLITZ_DEPLOY_VAR_PAYMENT_URL: ${{ secrets.PAYMENT_URL }}
run: npm run deploy -w packages/control-plane
# A green deploy command proves upload, not content. Ask the deployment
# which commit it is, and fail when it disagrees.
- name: Verify the deployment reports this commit
if: steps.gate.outputs.configured == 'true'
run: |
origin="$(node -e 'const fs=require("node:fs");import("smol-toml").then(({parse})=>{const v=parse(fs.readFileSync("packages/control-plane/wrangler.toml","utf8")).vars||{};process.stdout.write(v.APP_URL||"")})')"
if [ -z "$origin" ]; then
echo "::warning::PROD_WRANGLER_TOML sets no APP_URL, so the deploy cannot be verified from here."
exit 0
fi
for attempt in 1 2 3 4 5; do
reported="$(curl -fsS "$origin/version" | jq -r '.commit // empty' || true)"
[ "$reported" = "$GITHUB_SHA" ] && echo "$origin reports $reported" && exit 0
echo "attempt $attempt: reported '${reported:-none}', expected $GITHUB_SHA"
[ "$attempt" = "5" ] || sleep 6
done
echo "::error::$origin/version reports '${reported:-none}', not $GITHUB_SHA."
exit 1
release-notes:
name: Add image digests to GitHub Release
needs: images
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BOX_DIGEST: ${{ needs.images.outputs.box-digest }}
IMAGE_OWNER: ${{ needs.images.outputs.image-owner }}
steps:
- name: Create or update release notes
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --generate-notes
fi
# Re-running a release must replace the digest section, not append a
# second one, so strip any section this step wrote before and rebuild
# it from the digests this run pushed.
# Command substitution drops trailing newlines, so the separator below
# is the only one and the output is byte-identical on every re-run.
kept="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq .body \
| awk '/^## Container image digests$/ { exit } { print }')"
{
printf '%s\n\n' "$kept"
printf '## Container image digests\n\n'
printf -- '- `ghcr.io/%s/blitz-box:%s` — `%s`\n' "$IMAGE_OWNER" "$tag" "$BOX_DIGEST"
} >"$RUNNER_TEMP/release-notes.md"
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$RUNNER_TEMP/release-notes.md"