diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 037570419..985a0c309 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -64,13 +64,6 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 3 - ignore: - # The Dockerfile's `pgclient` stage carries pg_dump and pg_restore of - # the bundled database's major. A major bump there is part of the same - # migration decision as the database itself, and the runner's paths and - # version checks name major 18, so it arrives by hand, not as a PR. - - dependency-name: "postgres" - update-types: ["version-update:semver-major"] # The `docker` ecosystem above reads the Dockerfile only. The bundled # database lives in `docker-compose.yml`, which GitHub treats as its own # ecosystem, so without this block a new PostgreSQL minor release never diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 600271673..53f34f46c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -247,7 +247,7 @@ jobs: # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built # image fails the job. For a finding without an upstream fix - # (e.g. a base-image CVE Debian has not patched yet), add the CVE + # (e.g. a base-image CVE Wolfi has not patched yet), add the CVE # id to a `.trivyignore` file at the repository root — Trivy reads # it automatically — one CVE per line, each with a comment naming # the tracking issue and the re-check condition. Never suppress a diff --git a/Dockerfile b/Dockerfile index 7adac7ffb..419035ea9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,17 @@ -# ── Base image ───────────────────────────────────────────── -# Debian (glibc), not Alpine (musl), in every stage. The native modules the -# runtime loads (the @napi-rs/canvas Skia renderer, @node-rs/argon2, the -# Prisma schema engine) then run their glibc builds. The arm64 musl Skia -# build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with SIGILL on an -# ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the glibc arm64 -# build does not use those instructions (#1311). trixie over bookworm: on -# the day of the switch the bookworm-slim base carried a CRITICAL finding -# (zlib, marked will-not-fix) that trixie-slim does not. +# ── Base images ──────────────────────────────────────────── +# glibc, not musl, in every stage. The native modules the runtime loads (the +# @napi-rs/canvas Skia renderer, @node-rs/argon2, the Prisma schema engine) +# then run their glibc builds. The arm64 musl Skia build needs ARMv8.2 (LSE +# atomics, RCpc loads) and dies with SIGILL on an ARMv8.0 core such as the +# Raspberry Pi 4's Cortex-A72; the glibc arm64 build does not use those +# instructions (#1311). +# +# The dependency and build stages run on Debian (node:22-trixie-slim), where +# pnpm installs the `-gnu` native packages. The runner is Wolfi: glibc, and +# a package set small enough that the image scan reports what the app ships +# rather than a general-purpose distribution's base system. A glibc binary +# runs on the glibc it was linked against or a newer one; the runner checks +# both that and the Node module ABI against the builder at build time. # ── Stage 1: Dependencies ────────────────────────────────── FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps @@ -67,6 +72,10 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build # name; see the note there for why guessing was wrong. RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ > /app/.pdfjs-version +# The Node module ABI and the glibc the native packages were installed for; +# the runner refuses to build on an older glibc or another module ABI. +RUN node -p "process.versions.modules + ' ' + process.report.getReport().header.glibcVersionRuntime + ' ' + process.version" \ + > /app/.native-abi # The same for @napi-rs/canvas, whose native binary must match its JS loader. RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \ > /app/.canvas-version @@ -76,62 +85,40 @@ RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" RUN node scripts/verify-message-catalogs.mjs --dir messages \ --config src/lib/i18n/config.ts --write /app/.message-locales -# ── Stage 3: PostgreSQL client tools ─────────────────────── -# `pg_dump` and `pg_restore` of major 18, and the libpq they link, taken from -# the official PostgreSQL image built on the same Debian release. Installing -# `postgresql-client-18` instead pulls Perl and Debian's client wrapper -# scripts into the runner for two binaries the app calls by name. Pinned to -# the same minor as the bundled database in docker-compose.yml; move the two -# together. The libpq file is found through the loader rather than by an -# architecture path, so the step is the same on amd64 and arm64. -FROM postgres:18.6-trixie@sha256:3a4264520e121176619f54ff867d12774bc81e95e7b84db58d63bccdb6144b81 AS pgclient -RUN set -eu; \ - mkdir -p /out/bin /out/lib; \ - cp /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /out/bin/; \ - LIBPQ="$(ldd /usr/lib/postgresql/18/bin/pg_dump | awk '$1 == "libpq.so.5" { print $3 }')"; \ - [ -n "$LIBPQ" ] || { echo "ERROR: pg_dump links no libpq.so.5"; exit 1; }; \ - cp -L "$LIBPQ" /out/lib/libpq.so.5 - -# ── Stage 4: Production runner ───────────────────────────── -FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner -# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware -# timestamp formatting) resolve to the actual offset instead of silently -# falling back to UTC; the slim base ships without it. +# ── Stage 3: Production runner ───────────────────────────── +FROM cgr.dev/chainguard/wolfi-base:latest@sha256:629d9acaceda7746cd9293365eb0d8e8fabf74e91830b05470cfbee20b53f447 AS runner +# `nodejs-22` is the runtime; the builder's Node 22 and this one share the +# module ABI, which the check below holds. `tzdata` is required so +# Europe/Berlin schedules (pg-boss cron, locale-aware timestamp formatting) +# resolve to the actual offset instead of silently falling back to UTC. # -# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest -# has not absorbed yet (the digest pin stays for build reproducibility; -# security patches from the Debian archive override it deliberately — -# CVE-2026-14456 in openssl was the first instance the container scan -# caught between base-image rebuilds). +# `apk upgrade` pulls in package security fixes the pinned base digest has +# not absorbed yet (the digest pin stays for build reproducibility; fixes +# from the Wolfi repository override it deliberately — CVE-2026-14456 in +# openssl was the first instance the container scan caught between +# base-image rebuilds). # -# `pg_dump` and `pg_restore` serve the nightly database dump, the -# pre-migration dump in the entrypoint and the `db-restore` service -# (docs/ops/disaster-recovery.md). Major 18 matches the bundled database; a -# client dumps servers of its own major and older, so it also serves an -# install still on its own Postgres 16. Debian trixie ships major 17, so the -# two binaries and their libpq come from the `pgclient` stage above; the -# Debian libraries libpq links (Kerberos, LDAP, SASL) come from apt below, so -# the build-time upgrade and the image scan see them. The copied binaries are -# outside dpkg: the scan does not see them and `apt-get upgrade` does not -# patch them, so a PostgreSQL client fix arrives by moving the `pgclient` -# pin. +# `postgresql-18-client` carries `pg_dump` and `pg_restore` for the nightly +# database dump, the pre-migration dump in the entrypoint and the +# `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the +# bundled database; a client dumps servers of its own major and older, so it +# also serves an install still on its own Postgres 16. # -# `ca-certificates` is the system trust store the OpenSSL-linked tools use -# (the Prisma schema engine, libpq) for a TLS connection to the database. -RUN set -eux; \ - export DEBIAN_FRONTEND=noninteractive; \ - apt-get update; \ - apt-get upgrade -y --no-install-recommends; \ - apt-get install -y --no-install-recommends \ - ca-certificates tzdata libgssapi-krb5-2 libldap2 libsasl2-2; \ - apt-get clean; \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* -COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/ -COPY --from=pgclient /out/lib/libpq.so.5 /usr/lib/postgresql/18/lib/libpq.so.5 -RUN echo /usr/lib/postgresql/18/lib > /etc/ld.so.conf.d/postgresql-18.conf && \ - ldconfig && \ - ! ldd /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore | grep -q "not found" -ENV PATH=/usr/lib/postgresql/18/bin:$PATH +# busybox, part of the base, is the shell the entrypoint runs under and +# carries `tar`/`gzip` for the GeoLite2 extraction and `addgroup`/`adduser`. +RUN apk add --no-cache nodejs-22 tzdata postgresql-18-client && \ + apk upgrade --no-cache && \ + mkdir -p /usr/local/bin +COPY --from=builder /app/.native-abi /tmp/.native-abi +RUN set -e; \ + read -r BUILD_ABI BUILD_GLIBC BUILD_NODE < /tmp/.native-abi; \ + rm -f /tmp/.native-abi; \ + RUN_ABI="$(node -p process.versions.modules)"; \ + RUN_GLIBC="$(node -p 'process.report.getReport().header.glibcVersionRuntime')"; \ + [ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI $RUN_ABI, builder $BUILD_ABI"; exit 1; }; \ + [ "$(printf '%s\n%s\n' "$BUILD_GLIBC" "$RUN_GLIBC" | sort -V | head -1)" = "$BUILD_GLIBC" ] || \ + { echo "ERROR: runner glibc $RUN_GLIBC is older than the builder's $BUILD_GLIBC"; exit 1; }; \ + echo "native ABI $RUN_ABI (builder $BUILD_NODE, runner $(node -v)); glibc $BUILD_GLIBC -> $RUN_GLIBC" WORKDIR /app ENV NODE_ENV=production @@ -162,9 +149,8 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ARG NEXT_PUBLIC_APP_BUILD_SHA ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \ - --shell /usr/sbin/nologin nextjs +RUN addgroup -S -g 1001 nodejs && \ + adduser -S -u 1001 -G nodejs -h /home/nextjs -s /sbin/nologin nextjs # Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a # fresh named volume mounted here starts with that owner: Docker fills an @@ -327,8 +313,16 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \ # Install the migration CLI, its config dependency, and a pinned launcher for # the maintenance scripts already shipped in the standalone tree. The Prisma -# config loads dotenv from /app, so expose the isolated copy there before -# stripping npm/Corepack and their caches from the runtime surface. +# config loads dotenv from /app, so expose the isolated copy there. npm is +# installed for this one step and removed in the same layer, with its cache, +# so no package manager is left in the runtime surface. +# +# Wolfi's npm is major 12, which runs no dependency install scripts unless +# package.json lists the package under `allowScripts`. Three packages here +# have one: `@prisma/engines` downloads the schema engine `migrate deploy` +# runs, `prisma` checks its environment, and `esbuild` (under tsx) checks +# its binary. Without the field the install succeeds and ships no schema +# engine, so the check after this step asks the CLI for it. # # This install belongs to npm, not pnpm, so the `overrides` block in # `pnpm-workspace.yaml` does not reach it: a package pinned there for the app @@ -354,19 +348,29 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \ # npm install never reads. Rebuilding this install both ways confirms it: # without the two fields below npm resolves 1.19.11 and 1.2.0, with them it # resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0. -RUN mkdir -p /opt/prisma-cli && \ +RUN apk add --no-cache npm && \ + mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ npm init -y && \ npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \ npm pkg set 'overrides.mysql2=^3.22.0' && \ npm pkg set 'overrides.@hono/node-server=^1.19.15' && \ npm pkg set 'overrides.valibot=^1.4.2' && \ + npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \ npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 && \ ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \ ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \ ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \ - rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \ - rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx + apk del --no-cache npm && \ + rm -rf /root/.npm /root/.cache && \ + sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v pnpm' + +# The schema engine the entrypoint's `migrate deploy` runs is in place. +# Look for the file before asking the CLI: a CLI that finds none downloads +# one, which would hide the missing install script here and fail offline. +RUN ls /opt/prisma-cli/node_modules/@prisma/engines/schema-engine-* >/dev/null && \ + node /opt/prisma-cli/node_modules/prisma/build/index.js --version 2>&1 | \ + grep -q "Schema Engine *: schema-engine-cli [0-9a-f]* (at " # Same proof for the intake-repair script under the launcher it is documented # with: its `pg` import resolves, so it reaches its own DATABASE_URL check. @@ -389,7 +393,7 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | # user so the runtime worker can WRITE it: a self-hoster who sets # MAXMIND_LICENSE_KEY on the published image has the databases fetched into # this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or -# manual mount. `tar`/`gzip` for the extraction are part of the Debian base. A +# manual mount. `tar`/`gzip` for the extraction come with busybox. A # read-only bind mount at this path (the bring-your-own-database route) still # works — the runtime fetch just fails soft and the mounted files stand. RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2 @@ -419,8 +423,8 @@ ENV HOSTNAME="0.0.0.0" # Five retries: a pool saturated for a minute during a boot storm makes the # probe's 2 s database check miss once or twice without marking the container # unhealthy (docker-compose.yml explains the numbers). The probe is Node's own -# fetch: the Debian base carries neither wget nor curl, and installing one for -# this line would add a TLS stack to the image's scan surface. A non-2xx +# fetch: it needs nothing beyond the runtime, the same probe works on any +# base, and no HTTP client has to be installed for one line. A non-2xx # answer, a refused connection and the 4 s abort all exit 1. HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \ CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] diff --git a/README.md b/README.md index c5f60968c..f605cbc78 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl ## Tech stack -Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Debian (glibc) Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). +Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage glibc Docker image (Debian build, Wolfi runtime). The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). ## Documentation diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 156a7333e..c602b9c12 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -10,7 +10,7 @@ already installed, longer if a TLS-fronted public hostname is in scope - **Docker 24+ with Compose v2.** The bundled `docker-compose.yml` pulls a multi-arch image from GitHub Container Registry (linux/amd64 and linux/arm64), so a Raspberry Pi 4 or 5 or an x86 VPS all work - unchanged. The image is Debian-based (glibc). Document thumbnails and + unchanged. The image runs on glibc (a Wolfi base). Document thumbnails and scanned-PDF rendering use a native renderer that runs on every arm64 board, the Pi 4 included, and on x86-64 CPUs with AVX2; on an older x86 CPU (Celeron/Atom-class NAS chips) it switches itself off and those two diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index 61911c6a9..ae4eb4b0f 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -224,7 +224,8 @@ describe("release container inputs", () => { const dockerfile = readRepoFile("Dockerfile"); const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? []; - expect(fromLines).toHaveLength(3); + // deps and builder; the runner is Wolfi (canvas-binary-hoist-guard). + expect(fromLines).toHaveLength(2); expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual( new Set([ "FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f", @@ -243,8 +244,11 @@ describe("release container inputs", () => { expect( dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g), ).toHaveLength(2); + // The runner installs npm for the Prisma CLI step only, removes it in the + // same layer, and fails the build if any package manager is left. + expect(dockerfile).toContain("apk del --no-cache npm"); expect(dockerfile).toContain( - "rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack", + "sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v pnpm'", ); expect(dockerfile).toContain("tsx@4.23.1"); expect(dockerfile).toContain( diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts index 092be8a34..ca985e095 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -61,20 +61,26 @@ describe("canvas binary hoist", () => { expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); }); - it("builds every Node stage on the glibc Debian base", () => { - const froms = DOCKERFILE.match(/^FROM \S+/gm) ?? []; - const node = froms.filter((from) => from.startsWith("FROM node:")); - expect(node).toHaveLength(3); - for (const from of node) { - expect(from).toMatch(/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64}$/); - } - // The only other stage supplies the PostgreSQL client tools, built on - // the same Debian release so its binaries find their libraries. - expect(froms.filter((from) => !from.startsWith("FROM node:"))).toEqual([ + it("installs on Debian and runs on Wolfi, both glibc", () => { + const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? []; + expect(froms).toEqual([ expect.stringMatching( - /^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64}$/, + /^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS deps$/, + ), + expect.stringMatching( + /^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS builder$/, + ), + expect.stringMatching( + /^FROM cgr\.dev\/chainguard\/wolfi-base:latest@sha256:[0-9a-f]{64} AS runner$/, ), ]); + // The native packages pnpm picked in the builder must load in the + // runner: same module ABI, and a glibc no older than the builder's. + expect(stage("builder")).toContain("> /app/.native-abi"); + expect(stage("runner")).toContain( + '[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI', + ); + expect(stage("runner")).toContain("is older than the builder's"); }); it("fails the build when the copied binary is another version", () => { diff --git a/src/__tests__/database-pin-is-watched.test.ts b/src/__tests__/database-pin-is-watched.test.ts index a9350eafd..84d2d4cea 100644 --- a/src/__tests__/database-pin-is-watched.test.ts +++ b/src/__tests__/database-pin-is-watched.test.ts @@ -40,32 +40,6 @@ describe("the bundled database pin", () => { } }); - it("ships pg_dump of the same minor the bundled database runs", () => { - const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec( - read("docker-compose.yml"), - ); - const client = - /^FROM postgres:(\d+\.\d+)-trixie@sha256:[0-9a-f]{64} AS pgclient$/m.exec( - read("Dockerfile"), - ); - expect(db, "the compose file pins the database").not.toBeNull(); - expect(client, "the Dockerfile has a pinned pgclient stage").not.toBeNull(); - // The two pins are one decision; a Dependabot PR moves them separately. - expect(client![1]).toBe(db![1]); - }); - - it("keeps a major bump of the client tools out of the Dockerfile watch too", () => { - const dependabot = read(".github/dependabot.yml"); - const docker = - /package-ecosystem:\s*"docker"\n([\s\S]*?)(?=\n\s*- package-ecosystem:)/.exec( - dependabot, - ); - expect(docker, "a docker ecosystem entry").not.toBeNull(); - expect(docker![1]).toMatch( - /dependency-name:\s*"postgres"[\s\S]{0,200}version-update:semver-major/, - ); - }); - it("lets a major version through only as a decision, never as an update PR", () => { const dependabot = read(".github/dependabot.yml"); // The first run of the compose ecosystem proposed 16.15 -> 18.6. A diff --git a/src/lib/documents/native-canvas-support.ts b/src/lib/documents/native-canvas-support.ts index 1006d68d9..1cdb407af 100644 --- a/src/lib/documents/native-canvas-support.ts +++ b/src/lib/documents/native-canvas-support.ts @@ -9,7 +9,7 @@ * restart loop (live incident: a Synology DS1520+ self-host, exit code 132 * ~18 s after every boot once the thumbnail backfill touched Skia). * - * The published image is Debian (glibc) and runs the glibc build. Under an + * The published image runs on glibc (Wolfi) and loads the glibc build. Under an * emulated AVX2-less Atom core that build drew a canvas and rendered * a PDF page to JPEG where the musl build died, but it still carries AVX2 code * and not every path it can take was exercised, so the x64 gate stays closed @@ -26,7 +26,7 @@ * fallback, and dies with SIGILL as soon as the module loads on an ARMv8.0 * core such as the Raspberry Pi 4's Cortex-A72 (#1311). The glibc arm64 build * uses neither and runs there, which is why the published image moved off - * Alpine. The musl branch stays for a host that runs the app on musl itself, + * musl. The musl branch stays for a host that runs the app on musl itself, * from source on Alpine or from an image built on it. * * Detection reads `/proc/cpuinfo` once on Linux: x64 requires the `avx2`