Bake the browser's runtime libraries into the base image
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m13s
Build Container / build-container (pull_request) Successful in 13m11s
Build App / build-linux (pull_request) Successful in 6m53s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped

`npx playwright install chromium` downloaded ~150 MB of browser that then
died with "error while loading shared libraries: libglib-2.0.so.0" —
verified, not inferred, against the current image. The image shipped none
of Chromium's shared libraries, which is why `apt install
google-chrome-stable` looked like the cure: apt was quietly installing the
same set as Chrome's own dependencies.

Installing them at runtime instead converges on the worst possible state.
The libraries land in the container's writable layer, so they are re-paid
after every Reset and *lost* on base-image migration, which replays apt
from a manifest. The browsers ride in ~/.cache/ms-playwright, inside the
home volume, and survive both — leaving a 400 MB browser present with its
libraries gone. So the libraries are baked and the browsers are not: each
half now lives where it already persists.

The layer runs `npx --yes playwright@latest install-deps chromium` rather
than a hand-written apt list. Ubuntu 24.04's 64-bit-time_t transition
renamed a swathe of these packages (libasound2t64, libatk1.0-0t64,
libglib2.0-0t64, …) and a new Chromium dependency would drift straight back
into the launch failure this exists to prevent; letting Playwright name its
own dependencies is self-maintaining. It sits immediately after Node — npx
is its only prerequisite — and well above the shim COPYs, so editing a shim
does not re-run it.

The `--dry-run` that follows is a build-time assertion, not decoration: on a
platform Playwright has no list for, `install-deps` prints a warning and
returns having installed **nothing, with exit status 0**. Without the
assertion that ships a broken image behind a clean build log.

Measured, on a build of this file with the layer applied over an otherwise
identical image: +99 packages, +334 MiB unpacked and +119 MiB compressed
(2950 → 3284 MiB, 759 → 878 MiB). Two thirds of that is not reachable by
trimming — libgbm1, which Chromium needs, pulls mesa-libgallium, which
pulls libllvm20. A chromium-only apt list measures 247 MiB against
install-deps' 341 MiB; the ~94 MiB difference is xvfb and the CJK/emoji
fonts, kept because the base ships no fonts at all and every page this
feature exists to display would otherwise render as tofu.

Verified on real builds, both architectures: a `--platform linux/arm64`
build of this file installs the same 99 packages and passes the same
assertion. On the new amd64 image, `playwright install chromium` with no
`--with-deps` and no `install-deps` launches headless Chromium 151.0.7922.34
and loads a page; on the old image the identical script fails on
libglib-2.0.so.0.

`install.rs` no longer runs `install-deps` unconditionally — that would be a
minutes-long apt run for nothing on a current image. It asks
`install-deps --dry-run` first and skips the install when everything is
present, saying which of the two happened on the progress stream. The check
is Playwright's rather than a probe of our own for library names, so check
and fix cannot disagree about what the dependency set is. Note that
`--dry-run` exits 0 both when everything is installed and when Playwright
has no list for the platform, so the verdict is read from its output.

Containers on older images stay the normal case until people migrate, and
they still work: on such an image the simulation cannot even resolve the
package names (the index is cleaned in every base image), which reports as
"couldn't tell" and installs — the right answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
2026-08-10 10:57:21 -07:00
co-authored by Claude Opus 5
parent a5bcc462a7
commit 4fdfed7955
6 changed files with 389 additions and 52 deletions
@@ -310,8 +310,11 @@ function missingParts(d: PlaywrightDetection | null): string[] {
* The old pane printed npm commands here and left the rest to the user. The
* result, verified with a real one: an `@playwright/mcp` install that could
* never satisfy this pane, a global install that hit EACCES, a Chromium that
* downloaded and then would not start because the image ships none of its
* shared libraries, and a long tail of commands after that.
* downloaded and then would not start because the image shipped none of its
* shared libraries, and a long tail of commands after that. Current base images
* bake those libraries in, so that last one is fixed at the source — but a
* project keeps its original base image until it is migrated, so the install
* action still handles a container that lacks them.
*/
function Setup({
detection,
@@ -386,11 +389,13 @@ function Setup({
title="2. A browser to drive"
detail={
<>
Both install the system libraries first the base image ships none of them,
which is why a browser can download successfully and then refuse to start
and both end by actually launching the browser to prove it works. Browsers
land in <Code>~/.cache/ms-playwright</Code>, which is on the home volume, so
they survive container recreation and are only lost on a project Reset.
Both check the system libraries a browser links against first. Current base
images ship them, so that step is normally skipped; a container built from an
older image gets them installed with apt, which is the difference between a
browser that downloads successfully and one that also starts. Both end by
actually launching the browser to prove it works. Browsers land in{" "}
<Code>~/.cache/ms-playwright</Code>, which is on the home volume, so they
survive container recreation and are only lost on a project Reset.
</>
}
done={browsers.length > 0 || chrome !== null}