diff --git a/.github/scripts/macos-runner-images.test.mjs b/.github/scripts/macos-runner-images.test.mjs new file mode 100644 index 000000000..bfb13eb87 --- /dev/null +++ b/.github/scripts/macos-runner-images.test.mjs @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// Read as text, like scripts/ffmpeg-linked-libraries.test.mjs: a YAML parser would mean a new +// dependency for one assertion, and `runs-on:` is a single line either way. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const workflowsDir = path.join(repoRoot, ".github/workflows"); + +const workflows = fs + .readdirSync(workflowsDir) + .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")) + .map((name) => ({ name, text: fs.readFileSync(path.join(workflowsDir, name), "utf8") })); + +/** Every macOS runner label a workflow names, wherever it appears — `runs-on:` directly, or a + * matrix `os:` that feeds one. Both spellings are in use here. + * + * Every label ON the line, not the first: build.yml picks its image with a ternary inside the + * `runs-on:` expression, so a rule that read only the first match would never see the arm64 + * half of `matrix.arch == 'x64' && 'macos-15-intel' || 'macos-latest'` — the exact place a + * pin could hide from this file. */ +function macRunnerLabels({ text }) { + return text + .split("\n") + .filter((line) => /^\s*(?:runs-on|-? *os):/.test(line)) + .flatMap((line) => line.match(/macos[\w.-]*/g) ?? []); +} + +describe("macOS runner images", () => { + it("pins no arm64 macOS image", () => { + // The one that bit us: `macos-14` kept working until Homebrew stopped building bottles + // for it, at which point `brew install ffmpeg` fell back to compiling x265 from source + // and every pull request went red — including ones touching only `website/`. Homebrew + // builds bottles for the versions it supports, and `macos-latest` is always one of + // them, so a pinned arm64 image is a dated bomb with nothing to defuse it. + // + // Intel is the exception below, not an oversight: there is no floating Intel label. + // + // `-xlarge` counts. GitHub's larger runners spell arm64 that way (`macos-15-xlarge`), + // and a version-pinned xlarge carries exactly the same dated bottle problem as a bare + // `macos-15` — it just does not look like one. `-large`, confusingly, is Intel. + const pinned = workflows.flatMap((w) => + macRunnerLabels(w) + .filter((label) => /^macos-\d+(\.\d+)?(-arm64|-xlarge)?$/.test(label)) + .map((label) => `${w.name}: ${label}`), + ); + expect(pinned).toEqual([]); + }); + + it("allows a pinned Intel image, because no floating one exists", () => { + // `macos-15-intel` is deliberate: the x64 legs need an Intel runner and GitHub publishes + // no `macos-latest-intel`. When Homebrew drops macOS 15 this WILL break the same way, + // and the fix is to move to whatever Intel image exists then — not to reach for a + // floating label that does not. + const intel = workflows.flatMap((w) => + macRunnerLabels(w).filter((label) => label.endsWith("-intel")), + ); + expect(intel.length).toBeGreaterThan(0); + for (const label of intel) expect(label).toMatch(/^macos-\d+-intel$/); + }); + + it("keeps the compositor job on a floating arm64 runner", () => { + // Named explicitly: this job is the reason the rule exists, and it is the one with a + // hard arm64 requirement — a real Metal device, /opt/homebrew paths and the aarch64 + // toolchain — so a future "just use macos-latest-intel" would break it differently. + // + // The whole value, not a substring: `macos-latest-large` starts with `macos-latest` + // and is Intel, so `toContain` would wave through the one substitution that silently + // breaks every hardcoded /opt/homebrew path in the steps below it. + const ci = workflows.find((w) => w.name === "ci.yml"); + expect(ci).toBeDefined(); + const job = ci.text.slice(ci.text.indexOf("rust-macos-compositor-check:")); + const header = job.slice(0, job.indexOf("steps:")); + expect(header.match(/^\s*runs-on:\s*(\S+)\s*$/m)?.[1]).toBe("macos-latest"); + }); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ee421be9..493436c36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,15 +132,24 @@ jobs: # * `shaders.metal` is compiled AT RUNTIME by `new_library_with_source`, so a # whole file of invalid MSL (HLSL's global `cbuffer`/`Texture2D` have no MSL # equivalent) type-checks perfectly — `every_shader_entry_point_compiles` - # catches it. macos-14 runners have a real Metal device, so it runs for real. + # catches it. The arm64 runners have a real Metal device, so it runs for real. # * `AVERROR(EAGAIN)` is -11 on Windows/Linux and -35 on macOS. A hardcoded -11 # compiles anywhere and silently decodes zero frames — # `averror_constants_match_the_ffmpeg_macros` confronts it with shim.c. # * pipeline-state creation rejects shader/attachment mismatches only at # `newRenderPipelineState` time — `the_compositor_builds_on_the_system_device`. + # + # `macos-latest`, not a pinned image, and that is load-bearing: this job installs ffmpeg + # from homebrew-core at run time, and Homebrew only builds bottles for the macOS versions + # it still supports. Pinned to macos-14 it fell off that list, fell back to compiling x265 + # from source, and died in libtool — failing every pull request, including ones that touch + # nothing but `website/`. What the job actually needs is an arm64 runner (a real Metal + # device for the tests below, `/opt/homebrew` paths, the aarch64 toolchain), and that is + # what `macos-latest` has been since macos-14. Re-pin it and this breaks again on + # Homebrew's schedule rather than on ours. rust-macos-compositor-check: name: Rust test (macOS compositor) - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v7 - name: Install Rust toolchain