Public: anyone can read this; the crew can change it.

A forge runs on ARM: publish an arm64 image #12359

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12350-a-forge-runs-on-arm-publish-an-arm64-ima → main 2 commits fork

  • Merged. scout/12350-a-forge-runs-on-arm-publish-an-arm64-ima was merged into main as 4915f35.

Review

Sign in to review this change.
5 files changed +67 −9
.forgejo/workflows/release.yml +16 −1
@@ -111,12 +111,27 @@ jobs:
111111 sleep 30
112112 done
113113
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)
115129 run: |
116130 set -eu
117131 buildctl --addr "$BUILDKIT_HOST" build \
118132 --frontend dockerfile.v0 \
119133 --local context=src --local dockerfile=src \
134 --opt platform=linux/amd64,linux/arm64 \
120135 --opt build-arg:VERSION="$V" \
121136 --opt build-arg:COMMIT="$(git -C src rev-parse HEAD)" \
122137 --output type=image,name=$REG_PUB/$OWNER/$APP:$V,push=true \
Dockerfile +3 −1
@@ -46,7 +46,9 @@ USER bun
4646 # directory must answer /api/v1/health as $VERSION, or the build fails and
4747 # nothing is pushed. 0.7.166 passed its check from the repository and crashed
4848 # 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
5052 # tini reaps what an agent run leaves behind: a claude run spawns git, bun,
5153 # the app under test, and their children.
5254 ENTRYPOINT ["tini", "--"]
docs/ops/ROLL.md +12 −3
@@ -214,8 +214,16 @@ A release ships on two proofs, and nothing else holds it:
214214 `FORGE_URL` and secret `FORGE_TOKEN`, the two the flake hunt already
215215 needs.
216216 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).
219227
220228 An image in the registry has therefore passed both. The mirror runs no copy
221229 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/`:
235243
236244 - **At build.** The Dockerfile runs `bun src/release/boot.ts` on the image it
237245 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
239248 nothing is pushed. The image ships `src/` alone; `test/src-imports.test.ts`
240249 refuses any import from `scripts/` or `test/`.
241250 - **Before the roll.** The chart's pre-upgrade hook
src/release/boot.ts +3 −3
@@ -36,7 +36,7 @@ const POLL_MS = 250;
3636 const STALE_MS = 60 * 60 * 1000;
3737 const SCRATCH = "boot-proof-";
3838
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 };
4040
4141 export interface BootInput {
4242 /** The directory holding `src/main.ts`. */
@@ -128,7 +128,7 @@ async function bootOnPort(
128128 };
129129 }
130130 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 };
132132 if (seen !== undefined) last = `health answered as ${seen}`;
133133 await Bun.sleep(POLL_MS);
134134 }
@@ -234,7 +234,7 @@ if (import.meta.main) {
234234 const tree = join(import.meta.dir, "..", "..");
235235 const result = await proveBoot({ tree, version, ...args });
236236 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})`);
238238 else console.error(`boot failed: ${version} on ${what} ${result.line}`);
239239 process.exit(result.ok ? 0 : 1);
240240 }
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
124124 const docker = await Bun.file(join(repo, "Dockerfile")).text();
125125 const lines = docker.split("\n");
126126 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");
128128 expect(copied).toBeGreaterThan(-1);
129129 expect(proved).toBeGreaterThan(copied);
130130 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 () => {
167167 expect((await bootRestored(root, 30_000, other.dir)).ok).toBe(false);
168168 expect(other.runs()).toBe(1);
169169 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs