From 5d99792a8000785fe97921a1091bea96241a1fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Wed, 9 Sep 2026 14:22:25 +0200 Subject: [PATCH 1/2] fix: authenticate camoufox fetch with GITHUB_TOKEN `camoufox fetch` lists releases from api.github.com. Unauthenticated, every leg of the image matrix shares one runner IP's 60 requests/hour budget, so a leg occasionally loses the race and gets a 403. On the Python side that failure is silent: the sync swallows the error, `camoufox fetch` still exits 0, and the image ships with no browser. It then dies one step later in "Test image" with CamoufoxNotInstalled. Seen in run 34317591187, where 24 of the 25 camoufox legs on the same SHA passed. Pass GITHUB_TOKEN into both camoufox builds as a BuildKit secret, which lifts the budget to 1000 requests/hour per repository. A secret rather than a build arg keeps the token out of the image layers and out of `docker history`. Builds that pass no secret stay unauthenticated and still work. mode=0444 on the node mount because that stage runs as myuser and secrets default to root-owned 0400. Also assert on the Python side that the browser really landed, so any future fetch failure is a red build naming the cause instead of a green build shipping a broken image. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-node-playwright.yaml | 8 ++++++++ .github/workflows/release-python-playwright.yaml | 4 ++++ node-playwright-camoufox/Dockerfile | 11 +++++++++-- python-playwright-camoufox/Dockerfile | 13 +++++++++++-- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-node-playwright.yaml b/.github/workflows/release-node-playwright.yaml index 1062b1ab..2aa8106e 100644 --- a/.github/workflows/release-node-playwright.yaml +++ b/.github/workflows/release-node-playwright.yaml @@ -240,6 +240,8 @@ jobs: tags: ${{ fromJson(steps.prepare-tags.outputs.result).allTags }} cache-from: type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }} cache-to: type=gha,mode=max,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }} + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} - name: Test image run: docker run ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} @@ -281,6 +283,8 @@ jobs: type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }}-slim type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }} cache-to: type=gha,mode=max,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }}-slim + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} - name: Test slim image run: docker run ${{ fromJson(steps.prepare-slim-tags.outputs.result).firstImageName }} @@ -351,6 +355,8 @@ jobs: tags: ${{ fromJson(steps.prepare-tags.outputs.result).allTags }} outputs: type=image,oci-mediatypes=true cache-from: type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }} + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} - name: Build and push slim OCI image if: github.event_name != 'pull_request' @@ -370,6 +376,8 @@ jobs: cache-from: | type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }}-slim type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }} + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} # Aggregate the per-image size reports uploaded by the build matrix and post/update # a single sticky PR comment comparing current vs new image sizes. diff --git a/.github/workflows/release-python-playwright.yaml b/.github/workflows/release-python-playwright.yaml index 18f7ec74..f00433be 100644 --- a/.github/workflows/release-python-playwright.yaml +++ b/.github/workflows/release-python-playwright.yaml @@ -183,6 +183,8 @@ jobs: tags: ${{ fromJson(steps.prepare-tags.outputs.result).allTags }} cache-from: type=gha,scope=${{ matrix.image-name }}-${{ matrix.python-version }}-${{ matrix.playwright-version }}-${{ matrix.camoufox-version }} cache-to: type=gha,mode=max,scope=${{ matrix.image-name }}-${{ matrix.python-version }}-${{ matrix.playwright-version }}-${{ matrix.camoufox-version }} + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} - name: Test image run: docker run ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} @@ -245,6 +247,8 @@ jobs: tags: ${{ fromJson(steps.prepare-tags.outputs.result).allTags }} outputs: type=image,oci-mediatypes=true cache-from: type=gha,scope=${{ matrix.image-name }}-${{ matrix.python-version }}-${{ matrix.playwright-version }}-${{ matrix.camoufox-version }} + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} # Aggregate the per-image size reports uploaded by the build matrix and post/update # a single sticky PR comment comparing current vs new image sizes. diff --git a/node-playwright-camoufox/Dockerfile b/node-playwright-camoufox/Dockerfile index 6a6b4089..884cff67 100644 --- a/node-playwright-camoufox/Dockerfile +++ b/node-playwright-camoufox/Dockerfile @@ -135,10 +135,17 @@ ARG SLIM=0 RUN if [ "$SLIM" = "1" ]; then mv package.slim.json package.json; else rm package.slim.json; fi # Install default dependencies, print versions of everything -RUN npm --quiet set progress=false \ +# +# `camoufox-js fetch` reads api.github.com, where the whole matrix shares one 60/hour budget. +# GITHUB_TOKEN lifts it to 1000/hour; a secret, not a build arg, keeps it out of the layers. +# mode=0444 because this stage runs as myuser and secrets default to root-owned 0400. +# Without it the build stays unauthenticated and still works. +RUN --mount=type=secret,id=github_token,mode=0444 \ + npm --quiet set progress=false \ \ # Install Camoufox browser - && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0 npx camoufox-js fetch \ + && GITHUB_TOKEN="$(cat /run/secrets/github_token 2>/dev/null || true)" \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0 npx camoufox-js fetch \ # Overrides the dynamic library used by Firefox to determine trusted root certificates with p11-kit-trust.so, which loads the system certificates. && rm -f /home/myuser/.cache/camoufox/libnssckbi.so \ && ln -s $(ls -d /usr/lib/*-linux-gnu)/pkcs11/p11-kit-trust.so /home/myuser/.cache/camoufox/libnssckbi.so \ diff --git a/python-playwright-camoufox/Dockerfile b/python-playwright-camoufox/Dockerfile index 2222db6e..f7f01847 100644 --- a/python-playwright-camoufox/Dockerfile +++ b/python-playwright-camoufox/Dockerfile @@ -103,14 +103,23 @@ ENV PATH="/root/.local/bin:/home/myuser/.local/bin:$PATH" # - Preinstalls the latest versions of setuptools and wheel to improve package installation speed # - Installs the specified version of Playwright and Camoufox # - Fetches the Camoufox browser -RUN python -m pip install --upgrade \ +# +# `camoufox fetch` reads api.github.com, where the whole matrix shares one 60/hour budget. +# GITHUB_TOKEN lifts it to 1000/hour; a secret, not a build arg, keeps it out of the layers. +# Without it the build stays unauthenticated and still works. +RUN --mount=type=secret,id=github_token \ + python -m pip install --upgrade \ pip \ setuptools \ wheel \ playwright~=${PLAYWRIGHT_VERSION} \ camoufox[geoip]~=${CAMOUFOX_VERSION} \ # Fetch the Camoufox browser - && python -m camoufox fetch \ + && GITHUB_TOKEN="$(cat /run/secrets/github_token 2>/dev/null || true)" \ + python -m camoufox fetch \ + # `camoufox fetch` swallows sync errors and exits 0, so check what the runtime checks + # rather than ship an image that only fails at launch. + && python -c "from camoufox.pkgman import installed_verstr; print('Camoufox installed:', installed_verstr())" \ # Overrides the dynamic library used by Firefox to determine trusted root certificates with p11-kit-trust.so, which loads the system certificates. && rm -f /root/.cache/camoufox/libnssckbi.so \ && ln -s /usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so /root/.cache/camoufox/libnssckbi.so From 196fbfc8e64774d6f784394b86fb032fe272de4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 10 Sep 2026 09:30:02 +0200 Subject: [PATCH 2/2] fix: leave GITHUB_TOKEN unset when the secret is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from vladfrangu on #309. `GITHUB_TOKEN="$(cat ... 2>/dev/null || true)"` always set the variable, exporting an empty string to `camoufox fetch` when no secret was mounted. Both consumers treat "" as absent today (camoufox-js 0.12.0 `pkgman.js:24`, camoufox 0.5.6 `pkgman.py:467` both test truthiness), so nothing was broken, but the behaviour depended on that check. Read the secret only when it is readable, so an absent or unreadable file leaves the variable unset. `-r` rather than `-e` also covers the non-root node stage losing `mode=0444`, which previously degraded to an empty token instead of no token. Assignment and export are split to keep shellcheck SC2155 quiet. Also trims the comment blocks in both Dockerfiles. Verified in buildkit on a non-root stage: no secret -> unset; secret mounted -> token present; `mode=0444` removed -> unset, not empty. hadolint matches baseline on both files, zero new findings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 --- node-playwright-camoufox/Dockerfile | 13 +++++++------ python-playwright-camoufox/Dockerfile | 12 +++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/node-playwright-camoufox/Dockerfile b/node-playwright-camoufox/Dockerfile index 884cff67..d07eb1c1 100644 --- a/node-playwright-camoufox/Dockerfile +++ b/node-playwright-camoufox/Dockerfile @@ -136,16 +136,17 @@ RUN if [ "$SLIM" = "1" ]; then mv package.slim.json package.json; else rm packag # Install default dependencies, print versions of everything # -# `camoufox-js fetch` reads api.github.com, where the whole matrix shares one 60/hour budget. -# GITHUB_TOKEN lifts it to 1000/hour; a secret, not a build arg, keeps it out of the layers. -# mode=0444 because this stage runs as myuser and secrets default to root-owned 0400. -# Without it the build stays unauthenticated and still works. +# GITHUB_TOKEN raises the api.github.com rate limit for `camoufox-js fetch`. A secret, not a build +# arg, keeps it out of the layers; mode=0444 because this stage runs as myuser. Optional. RUN --mount=type=secret,id=github_token,mode=0444 \ npm --quiet set progress=false \ \ # Install Camoufox browser - && GITHUB_TOKEN="$(cat /run/secrets/github_token 2>/dev/null || true)" \ - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0 npx camoufox-js fetch \ + && if [ -r /run/secrets/github_token ]; then \ + GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ + export GITHUB_TOKEN; \ + fi \ + && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0 npx camoufox-js fetch \ # Overrides the dynamic library used by Firefox to determine trusted root certificates with p11-kit-trust.so, which loads the system certificates. && rm -f /home/myuser/.cache/camoufox/libnssckbi.so \ && ln -s $(ls -d /usr/lib/*-linux-gnu)/pkcs11/p11-kit-trust.so /home/myuser/.cache/camoufox/libnssckbi.so \ diff --git a/python-playwright-camoufox/Dockerfile b/python-playwright-camoufox/Dockerfile index f7f01847..deaa499f 100644 --- a/python-playwright-camoufox/Dockerfile +++ b/python-playwright-camoufox/Dockerfile @@ -104,9 +104,8 @@ ENV PATH="/root/.local/bin:/home/myuser/.local/bin:$PATH" # - Installs the specified version of Playwright and Camoufox # - Fetches the Camoufox browser # -# `camoufox fetch` reads api.github.com, where the whole matrix shares one 60/hour budget. -# GITHUB_TOKEN lifts it to 1000/hour; a secret, not a build arg, keeps it out of the layers. -# Without it the build stays unauthenticated and still works. +# GITHUB_TOKEN raises the api.github.com rate limit for `camoufox fetch`. A secret, not a build +# arg, keeps it out of the layers. Optional. RUN --mount=type=secret,id=github_token \ python -m pip install --upgrade \ pip \ @@ -115,8 +114,11 @@ RUN --mount=type=secret,id=github_token \ playwright~=${PLAYWRIGHT_VERSION} \ camoufox[geoip]~=${CAMOUFOX_VERSION} \ # Fetch the Camoufox browser - && GITHUB_TOKEN="$(cat /run/secrets/github_token 2>/dev/null || true)" \ - python -m camoufox fetch \ + && if [ -r /run/secrets/github_token ]; then \ + GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ + export GITHUB_TOKEN; \ + fi \ + && python -m camoufox fetch \ # `camoufox fetch` swallows sync errors and exits 0, so check what the runtime checks # rather than ship an image that only fails at launch. && python -c "from camoufox.pkgman import installed_verstr; print('Camoufox installed:', installed_verstr())" \