Public: anyone can read this; the crew can change it.
A forge runs on ARM: publish an arm64 image #12359
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12350-a-forge-runs-on-arm-publish-an-arm64-ima → main 2 commits fork
5 files
changed +67
−9
.forgejo/workflows/release.yml +16 −1
| @@ -111,12 +111,27 @@ jobs: | ||
| 111 | 111 | sleep 30 |
| 112 | 112 | done |
| 113 | 113 | |
| 114 | - name: build & push image (rootless buildkit) | |
| 114 | # One tag, an index of both architectures: the Dockerfile's boot proof runs | |
| 115 | # once per platform and prints the architecture it read ("boot ok: ... (arm64)"). | |
| 116 | # The builder needs arm64 emulation (binfmt/qemu) or an arm64 worker; the | |
| 117 | # guard step says so before the build, not at the boot proof after main's check wait. | |
| 118 | - name: the builder can build linux/arm64 | |
| 119 | run: | | |
| 120 | set -eu | |
| 121 | WORKERS=$(buildctl --addr "$BUILDKIT_HOST" debug workers) | |
| 122 | printf '%s\n' "$WORKERS" | grep -q 'linux/arm64' || { | |
| 123 | echo "FATAL: the buildkit builder at $BUILDKIT_HOST does not list linux/arm64 (needs binfmt/qemu or an arm64 worker); workers:" | |
| 124 | printf '%s\n' "$WORKERS" | |
| 125 | exit 1 | |
| 126 | } | |
| 127 | ||
| 128 | - name: build & push image (rootless buildkit, amd64 + arm64) | |
| 115 | 129 | run: | |
| 116 | 130 | set -eu |
| 117 | 131 | buildctl --addr "$BUILDKIT_HOST" build \ |
| 118 | 132 | --frontend dockerfile.v0 \ |
| 119 | 133 | --local context=src --local dockerfile=src \ |
| 134 | --opt platform=linux/amd64,linux/arm64 \ | |
| 120 | 135 | --opt build-arg:VERSION="$V" \ |
| 121 | 136 | --opt build-arg:COMMIT="$(git -C src rev-parse HEAD)" \ |
| 122 | 137 | --output type=image,name=$REG_PUB/$OWNER/$APP:$V,push=true \ |
Dockerfile +3 −1
| @@ -46,7 +46,9 @@ USER bun | ||
| 46 | 46 | # directory must answer /api/v1/health as $VERSION, or the build fails and |
| 47 | 47 | # nothing is pushed. 0.7.166 passed its check from the repository and crashed |
| 48 | 48 | # at import from this image. |
| 49 | RUN bun src/release/boot.ts | |
| 49 | # Under arm64 emulation bun is several times slower, so the budget is ten | |
| 50 | # minutes, not the two the live pre-upgrade proof keeps. | |
| 51 | RUN bun src/release/boot.ts --budget 600 | |
| 50 | 52 | # tini reaps what an agent run leaves behind: a claude run spawns git, bun, |
| 51 | 53 | # the app under test, and their children. |
| 52 | 54 | ENTRYPOINT ["tini", "--"] |
docs/ops/ROLL.md +12 −3
| @@ -214,8 +214,16 @@ A release ships on two proofs, and nothing else holds it: | ||
| 214 | 214 | `FORGE_URL` and secret `FORGE_TOKEN`, the two the flake hunt already |
| 215 | 215 | needs. |
| 216 | 216 | 2. **The built image boots.** The Dockerfile runs `bun src/release/boot.ts` |
| 217 | on the image it just built (below); a refusal fails the build and nothing | |
| 218 | is pushed. | |
| 217 | on the image it just built (below), once per platform; a refusal fails the | |
| 218 | build and nothing is pushed. | |
| 219 | ||
| 220 | Each tag publishes one index of `linux/amd64` and `linux/arm64`. A guard step | |
| 221 | before the build runs `buildctl debug workers` and fails at once, naming the | |
| 222 | builder, when `linux/arm64` is not listed: the builder needs binfmt/qemu or an | |
| 223 | arm64 worker. The arm64 half is built and boot-proved under emulation, which is | |
| 224 | why the Dockerfile's budget is 600 s. The proof prints the architecture it read | |
| 225 | (`boot ok: ... (arm64)`); read the build log for both lines. Nothing yet boots | |
| 226 | the published arm64 manifest after the push (#12374). | |
| 219 | 227 | |
| 220 | 228 | An image in the registry has therefore passed both. The mirror runs no copy |
| 221 | 229 | of the suite on a push, a pull or a tag: its `check.yml` held 0.7.170–0.7.173 |
| @@ -235,7 +243,8 @@ in `src/release/`: | ||
| 235 | 243 | |
| 236 | 244 | - **At build.** The Dockerfile runs `bun src/release/boot.ts` on the image it |
| 237 | 245 | just built: `src/main.ts` on an empty data directory must answer |
| 238 | `/api/v1/health` as `$VERSION` within two minutes, or the build fails and | |
| 246 | `/api/v1/health` as `$VERSION` within ten minutes (the emulated arm64 build | |
| 247 | is slow; the pre-upgrade hook below keeps two), or the build fails and | |
| 239 | 248 | nothing is pushed. The image ships `src/` alone; `test/src-imports.test.ts` |
| 240 | 249 | refuses any import from `scripts/` or `test/`. |
| 241 | 250 | - **Before the roll.** The chart's pre-upgrade hook |
src/release/boot.ts +3 −3
| @@ -36,7 +36,7 @@ const POLL_MS = 250; | ||
| 36 | 36 | const STALE_MS = 60 * 60 * 1000; |
| 37 | 37 | const SCRATCH = "boot-proof-"; |
| 38 | 38 | |
| 39 | export type BootProof = { ok: true; ms: number } | { ok: false; line: string }; | |
| 39 | export type BootProof = { ok: true; ms: number; arch: string } | { ok: false; line: string }; | |
| 40 | 40 | |
| 41 | 41 | export interface BootInput { |
| 42 | 42 | /** The directory holding `src/main.ts`. */ |
| @@ -128,7 +128,7 @@ async function bootOnPort( | ||
| 128 | 128 | }; |
| 129 | 129 | } |
| 130 | 130 | const seen = await versionAt(port, probeHeaders(input.dataDir)); |
| 131 | if (seen === input.version) return { result: { ok: true, ms: Math.round(performance.now() - started) }, portLost: false }; | |
| 131 | if (seen === input.version) return { result: { ok: true, ms: Math.round(performance.now() - started), arch: process.arch }, portLost: false }; | |
| 132 | 132 | if (seen !== undefined) last = `health answered as ${seen}`; |
| 133 | 133 | await Bun.sleep(POLL_MS); |
| 134 | 134 | } |
| @@ -234,7 +234,7 @@ if (import.meta.main) { | ||
| 234 | 234 | const tree = join(import.meta.dir, "..", ".."); |
| 235 | 235 | const result = await proveBoot({ tree, version, ...args }); |
| 236 | 236 | const what = args.from === undefined ? "an empty data directory" : `a copy of ${args.from}`; |
| 237 | if (result.ok) console.log(`boot ok: ${version} answered health on ${what} in ${result.ms}ms`); | |
| 237 | if (result.ok) console.log(`boot ok: ${version} answered health on ${what} in ${result.ms}ms (${result.arch})`); | |
| 238 | 238 | else console.error(`boot failed: ${version} on ${what} ${result.line}`); |
| 239 | 239 | process.exit(result.ok ? 0 : 1); |
| 240 | 240 | } |
test/release-boot.test.ts +33 −1
| @@ -124,7 +124,7 @@ test("the build runs the proof on the image it built, and the chart runs it on a | ||
| 124 | 124 | const docker = await Bun.file(join(repo, "Dockerfile")).text(); |
| 125 | 125 | const lines = docker.split("\n"); |
| 126 | 126 | const copied = lines.findIndex((l) => l.startsWith("COPY src ")); |
| 127 | const proved = lines.indexOf("RUN bun src/release/boot.ts"); | |
| 127 | const proved = lines.indexOf("RUN bun src/release/boot.ts --budget 600"); | |
| 128 | 128 | expect(copied).toBeGreaterThan(-1); |
| 129 | 129 | expect(proved).toBeGreaterThan(copied); |
| 130 | 130 | expect(proved).toBeGreaterThan(lines.findIndex((l) => l.startsWith("ENV FORGE_VERSION="))); |
| @@ -167,3 +167,35 @@ test("bootRestored retries a lost port and not another failure", async () => { | ||
| 167 | 167 | expect((await bootRestored(root, 30_000, other.dir)).ok).toBe(false); |
| 168 | 168 | expect(other.runs()).toBe(1); |
| 169 | 169 | }); |
| 170 | ||
| 171 | test("a passing proof names the architecture it read", async () => { | |
| 172 | const dir = tree("arch", serving("process.env.FORGE_VERSION")); | |
| 173 | const result = await proveBoot({ tree: dir, version: "1.2.3", budgetMs: 30_000 }); | |
| 174 | expect(result).toMatchObject({ ok: true, arch: process.arch }); | |
| 175 | }); | |
| 176 | ||
| 177 | function releaseStep(name: string): string { | |
| 178 | const yml = readFileSync(join(import.meta.dir, "..", ".forgejo", "workflows", "release.yml"), "utf8"); | |
| 179 | const start = yml.indexOf(`- name: ${name}`); | |
| 180 | expect(start).toBeGreaterThan(-1); | |
| 181 | const next = yml.indexOf("\n - name:", start + 1); | |
| 182 | return yml.slice(start, next === -1 ? undefined : next); | |
| 183 | } | |
| 184 | const code = (step: string) => step.split("\n").filter((l) => !l.trim().startsWith("#")).join("\n"); | |
| 185 | ||
| 186 | test("the release's buildctl build names both platforms, after a guard for arm64", () => { | |
| 187 | const yml = readFileSync(join(import.meta.dir, "..", ".forgejo", "workflows", "release.yml"), "utf8"); | |
| 188 | const build = code(releaseStep("build & push image (rootless buildkit, amd64 + arm64)")); | |
| 189 | const cmd = build.slice(build.indexOf("buildctl"), build.indexOf("--metadata-file")).replace(/\\\n/g, " "); | |
| 190 | expect(cmd).toMatch(/ build .*--opt platform=linux\/amd64,linux\/arm64 /); | |
| 191 | const guard = code(releaseStep("the builder can build linux/arm64")); | |
| 192 | expect(guard).toContain("buildctl"); | |
| 193 | expect(guard).toContain("debug workers"); | |
| 194 | expect(guard).toContain("linux/arm64"); | |
| 195 | expect(yml.indexOf("the builder can build linux/arm64")).toBeLessThan(yml.indexOf("build & push image")); | |
| 196 | }); | |
| 197 | ||
| 198 | test("the Dockerfile's emulated boot proof is given ten minutes", () => { | |
| 199 | const dockerfile = readFileSync(join(import.meta.dir, "..", "Dockerfile"), "utf8"); | |
| 200 | expect(dockerfile).toMatch(/^RUN bun src\/release\/boot\.ts --budget 600$/m); | |
| 201 | }); |
new above
offline