Updated CI to mirror playwright image to GHCR#27318
Conversation
WalkthroughThe changes introduce container image mirroring functionality for Playwright. A new composite GitHub Action is created to facilitate pulling, tagging, and pushing container images. The CI workflow is enhanced to detect the Playwright version from package.json, check for image existence in GHCR, and conditionally mirror it from Microsoft Container Registry when needed. The cleanup workflow is updated to manage the Playwright package in GHCR with version-based retention logic sorted by update timestamps. The e2e test script is modified to default to the GHCR-hosted Playwright image instead of the upstream source. This establishes automated synchronization and lifecycle management of the Playwright container image within the project's registry. 🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #27318 +/- ##
==========================================
- Coverage 73.49% 73.47% -0.02%
==========================================
Files 1545 1545
Lines 123720 123724 +4
Branches 14973 14971 -2
==========================================
- Hits 90922 90910 -12
- Misses 31777 31813 +36
+ Partials 1021 1001 -20
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Documented the proposed GHCR mirroring approach for Playwright so the CI changes can be reviewed and implemented from a single spec.
349ce33 to
467e08c
Compare
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/actions/mirror-container-image/action.yml:
- Around line 22-29: The current sequence uses docker pull -> docker tag ->
docker push which only mirrors a single platform variant; replace that block so
the action uses a manifest-aware copy tool (e.g., docker buildx imagetools
create or skopeo copy --all) to copy "$SOURCE_IMAGE" to "$TARGET_IMAGE" and
preserve the multi-arch manifest list; ensure the step performs any required
registry authentication beforehand and uses the existing SOURCE_IMAGE and
TARGET_IMAGE variables when invoking imagetools or skopeo so the upstream
manifest list is retained.
In @.github/workflows/ci.yml:
- Around line 244-275: The job job_mirror_playwright_image must always run as a
prerequisite for Playwright consumers; move its conditional logic out of the
job-level if (so the job never gets skipped) and instead apply the mirror-only
gating at the step level (the "Login to GHCR", "Mirror Playwright image", and
"Fail when Playwright image cannot be mirrored" steps) or create a separate
always-running gate job that performs the mirror-check/mirror action, then add
job_mirror_playwright_image (or the new gate job) to the needs chain of the
first Playwright-consuming job and job_required_tests so downstream jobs cannot
start until the mirror job completes.
- Around line 205-214: Change the logic that sets image-exists so only a 200 =>
image-exists=true and a 404 => image-exists=false; treat any other HTTP_CODE
(401/429/5xx/empty TOKEN) as a fatal error to fail the workflow instead of
proceeding to mirror. Specifically, after fetching TOKEN and computing HTTP_CODE
for the manifest URL (variables TOKEN, HTTP_CODE, PLAYWRIGHT_VERSION), if TOKEN
is empty or HTTP_CODE is not 200/404 print an informative error to stderr and
exit 1; if HTTP_CODE is 200 echo "image-exists=true" >> "$GITHUB_OUTPUT"; if 404
echo "image-exists=false" >> "$GITHUB_OUTPUT".
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: c37f7515-84e5-4eda-8411-004bb5fa4893
📒 Files selected for processing (4)
.github/actions/mirror-container-image/action.yml.github/workflows/ci.yml.github/workflows/cleanup-ghcr.ymle2e/scripts/load-playwright-container-env.sh
| echo "Pulling source image: $SOURCE_IMAGE" | ||
| docker pull "$SOURCE_IMAGE" | ||
|
|
||
| echo "Tagging image as: $TARGET_IMAGE" | ||
| docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" | ||
|
|
||
| echo "Pushing mirrored image: $TARGET_IMAGE" | ||
| docker push "$TARGET_IMAGE" |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does docker pull IMAGE && docker tag IMAGE TARGET && docker push TARGET preserve a multi-platform manifest list, or only the pulled platform? What is the recommended way to copy a multi-arch image between registries?
💡 Result:
docker pull IMAGE && docker tag IMAGE TARGET && docker push TARGET only preserves (and pushes) the single platform variant that got pulled, not the original multi-platform manifest list/image index. On docker pull, the registry may return a manifest list, but Docker then selects just the matching platform and pulls that image; the engine doesn’t use a locally-stored manifest list for subsequent operations like push. [1], [2]
Recommended ways to copy a multi-arch image between registries (without rebuilding):
-
Docker-native (recommended): copy the manifest list/index directly
docker buildx imagetools create --tag DEST_IMAGE:TAG SRC_IMAGE:TAG
If the source is a manifest list / OCI index,
imagetools createmakes a “carbon copy” to the destination tag. [3] -
Skopeo (common alternative):
skopeo copy --all docker://SRC_IMAGE:TAG docker://DEST_IMAGE:TAG
--allensures the manifest list and all referenced arch images are copied. [4]
Sources:
[1] Docker Docs – Multi-platform (pull selects correct variant) (docs.docker.com)
[2] Docker Docs – docker manifest (local manifest lists not used by engine on pull) (docs.docker.com)
[3] Docker Docs – docker buildx imagetools create (“carbon copy” of manifest list/index) (docs.docker.com)
[4] Google Cloud Blog – Skopeo copy --all copies entire manifest list and associated images (cloud.google.com)
Citations:
- 1: https://docs.docker.com/build/building/multi-platform/?utm_source=openai
- 2: https://docs.docker.com/reference/cli/docker/manifest/?utm_source=openai
- 3: https://docs.docker.com/reference/cli/docker/buildx/imagetools/create/?utm_source=openai
- 4: https://cloud.google.com/blog/topics/developers-practitioners/five-ways-skopeo-can-simplify-your-google-cloud-container-workflow/?utm_source=openai
Preserve the upstream manifest list when mirroring multi-platform images.
Lines 22-29 use docker pull, docker tag, and docker push, which only republishes the single platform variant that the runner pulled locally. For multi-arch Playwright tags, this produces a single-platform copy instead of a true multi-platform mirror. The registry returns a manifest list, but Docker selects only the matching platform during pull; subsequent tag and push operations don't preserve the original manifest structure.
Use a manifest-aware copy tool instead: docker buildx imagetools create, skopeo copy --all, or equivalent.
🔁 Suggested fix
- echo "Pulling source image: $SOURCE_IMAGE"
- docker pull "$SOURCE_IMAGE"
-
- echo "Tagging image as: $TARGET_IMAGE"
- docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"
-
- echo "Pushing mirrored image: $TARGET_IMAGE"
- docker push "$TARGET_IMAGE"
+ echo "Mirroring image as: $TARGET_IMAGE"
+ docker buildx imagetools create \
+ --tag "$TARGET_IMAGE" \
+ "$SOURCE_IMAGE"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| echo "Pulling source image: $SOURCE_IMAGE" | |
| docker pull "$SOURCE_IMAGE" | |
| echo "Tagging image as: $TARGET_IMAGE" | |
| docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" | |
| echo "Pushing mirrored image: $TARGET_IMAGE" | |
| docker push "$TARGET_IMAGE" | |
| echo "Mirroring image as: $TARGET_IMAGE" | |
| docker buildx imagetools create \ | |
| --tag "$TARGET_IMAGE" \ | |
| "$SOURCE_IMAGE" |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/actions/mirror-container-image/action.yml around lines 22 - 29, The
current sequence uses docker pull -> docker tag -> docker push which only
mirrors a single platform variant; replace that block so the action uses a
manifest-aware copy tool (e.g., docker buildx imagetools create or skopeo copy
--all) to copy "$SOURCE_IMAGE" to "$TARGET_IMAGE" and preserve the multi-arch
manifest list; ensure the step performs any required registry authentication
beforehand and uses the existing SOURCE_IMAGE and TARGET_IMAGE variables when
invoking imagetools or skopeo so the upstream manifest list is retained.
| # Check if the image already exists in GHCR (anonymous OCI registry check) | ||
| TOKEN="$(curl -sf "https://ghcr.io/token?service=ghcr.io&scope=repository:tryghost/playwright:pull" | jq -r .token)" || TOKEN="" | ||
| HTTP_CODE="$(curl -so /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" \ | ||
| "https://ghcr.io/v2/tryghost/playwright/manifests/v${PLAYWRIGHT_VERSION}-noble")" | ||
|
|
||
| if [ "$HTTP_CODE" = "200" ]; then | ||
| echo "image-exists=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "image-exists=false" >> "$GITHUB_OUTPUT" | ||
| fi |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
For GitHub Container Registry's /v2//manifests/ endpoint, which HTTP status codes indicate “manifest not found” versus auth, rate-limit, or server errors?
💡 Result:
For GET/HEAD /v2/<name>/manifests/<reference> (as implemented by GHCR via the OCI/Docker Distribution APIs):
- Manifest not found:
404 Not Found(manifest absent/unknown). [1] - Auth problems:
401 Unauthorized(missing/invalid auth; checkWWW-Authenticate). [2]403 Forbidden(authenticated but not allowed / insufficient scope). [2]
- Rate limit / throttling:
429 Too Many Requests. [2] - Server-side errors:
5xx(notably502,503,504are treated as temporary/retryable per the spec; other5xxare server failures). [2]
Sources: [1] OCI Distribution Spec (Pulling manifests) (oci-playground.github.io), [2] Docker Registry HTTP API V2 spec (auth, 403/429 errors, 5xx guidance) (matsuand.github.io)
Citations:
- 1: https://oci-playground.github.io/specs-latest/specs/distribution/v1.0.0/oci-distribution-spec.html
- 2: https://matsuand.github.io/docs.docker.jp.onthefly/registry/spec/api/
Distinguish manifest not found (404) from transient errors (401/429/5xx).
Lines 205–214 currently collapse every non-200 HTTP status into image-exists=false. A transient 401, 429, or 5xx error—or even a token lookup failure—will incorrectly trigger an unnecessary mirror attempt and hard-fail fork PRs even if the image already exists. Only 404 indicates the manifest is actually missing; other errors should cause the workflow to fail rather than proceed with mirroring.
🛡️ Suggested fix
- if [ "$HTTP_CODE" = "200" ]; then
- echo "image-exists=true" >> "$GITHUB_OUTPUT"
- else
- echo "image-exists=false" >> "$GITHUB_OUTPUT"
- fi
+ case "$HTTP_CODE" in
+ 200)
+ echo "image-exists=true" >> "$GITHUB_OUTPUT"
+ ;;
+ 404)
+ echo "image-exists=false" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "::error::Unexpected GHCR manifest response: $HTTP_CODE"
+ exit 1
+ ;;
+ esac📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # Check if the image already exists in GHCR (anonymous OCI registry check) | |
| TOKEN="$(curl -sf "https://ghcr.io/token?service=ghcr.io&scope=repository:tryghost/playwright:pull" | jq -r .token)" || TOKEN="" | |
| HTTP_CODE="$(curl -so /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" \ | |
| "https://ghcr.io/v2/tryghost/playwright/manifests/v${PLAYWRIGHT_VERSION}-noble")" | |
| if [ "$HTTP_CODE" = "200" ]; then | |
| echo "image-exists=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "image-exists=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Check if the image already exists in GHCR (anonymous OCI registry check) | |
| TOKEN="$(curl -sf "https://ghcr.io/token?service=ghcr.io&scope=repository:tryghost/playwright:pull" | jq -r .token)" || TOKEN="" | |
| HTTP_CODE="$(curl -so /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" \ | |
| "https://ghcr.io/v2/tryghost/playwright/manifests/v${PLAYWRIGHT_VERSION}-noble")" | |
| case "$HTTP_CODE" in | |
| 200) | |
| echo "image-exists=true" >> "$GITHUB_OUTPUT" | |
| ;; | |
| 404) | |
| echo "image-exists=false" >> "$GITHUB_OUTPUT" | |
| ;; | |
| *) | |
| echo "::error::Unexpected GHCR manifest response: $HTTP_CODE" | |
| exit 1 | |
| ;; | |
| esac |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/ci.yml around lines 205 - 214, Change the logic that sets
image-exists so only a 200 => image-exists=true and a 404 => image-exists=false;
treat any other HTTP_CODE (401/429/5xx/empty TOKEN) as a fatal error to fail the
workflow instead of proceeding to mirror. Specifically, after fetching TOKEN and
computing HTTP_CODE for the manifest URL (variables TOKEN, HTTP_CODE,
PLAYWRIGHT_VERSION), if TOKEN is empty or HTTP_CODE is not 200/404 print an
informative error to stderr and exit 1; if HTTP_CODE is 200 echo
"image-exists=true" >> "$GITHUB_OUTPUT"; if 404 echo "image-exists=false" >>
"$GITHUB_OUTPUT".
| job_mirror_playwright_image: | ||
| name: Mirror Playwright image | ||
| runs-on: ubuntu-latest | ||
| needs: [job_setup] | ||
| if: needs.job_setup.outputs.is_tag != 'true' && needs.job_setup.outputs.playwright_image_exists != 'true' | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
|
|
||
| - name: Login to GHCR | ||
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | ||
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 | ||
| with: | ||
| registry: ghcr.io | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Mirror Playwright image | ||
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | ||
| uses: ./.github/actions/mirror-container-image | ||
| with: | ||
| source-image: mcr.microsoft.com/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble | ||
| target-image: ghcr.io/tryghost/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble | ||
|
|
||
| - name: Fail when Playwright image cannot be mirrored | ||
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository | ||
| run: | | ||
| echo "::error::Playwright v${{ needs.job_setup.outputs.playwright_version }}-noble is not mirrored in GHCR yet. Publish ghcr.io/tryghost/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble before running E2E for this branch." | ||
| exit 1 |
There was a problem hiding this comment.
Make this an always-running prerequisite for Playwright consumers.
Nothing in the workflow currently needs this job, so on the first Playwright bump the E2E path can start pulling ghcr.io/tryghost/playwright:v…-noble before the mirror push finishes. That reintroduces the same race this PR is trying to eliminate. Also, adding this job to needs as-is is not enough, because its job-level if will skip dependents when the image already exists. The mirror condition needs to move to step level, or into an always-running gate job, before downstream jobs depend on it.
🔁 Suggested shape
job_mirror_playwright_image:
name: Mirror Playwright image
runs-on: ubuntu-latest
needs: [job_setup]
- if: needs.job_setup.outputs.is_tag != 'true' && needs.job_setup.outputs.playwright_image_exists != 'true'
+ if: needs.job_setup.outputs.is_tag != 'true'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GHCR
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ if: needs.job_setup.outputs.playwright_image_exists != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
- name: Mirror Playwright image
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ if: needs.job_setup.outputs.playwright_image_exists != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: ./.github/actions/mirror-container-imageThen add job_mirror_playwright_image to the first Playwright-consuming job's needs chain and to job_required_tests.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| job_mirror_playwright_image: | |
| name: Mirror Playwright image | |
| runs-on: ubuntu-latest | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.is_tag != 'true' && needs.job_setup.outputs.playwright_image_exists != 'true' | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Login to GHCR | |
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Mirror Playwright image | |
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | |
| uses: ./.github/actions/mirror-container-image | |
| with: | |
| source-image: mcr.microsoft.com/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble | |
| target-image: ghcr.io/tryghost/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble | |
| - name: Fail when Playwright image cannot be mirrored | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository | |
| run: | | |
| echo "::error::Playwright v${{ needs.job_setup.outputs.playwright_version }}-noble is not mirrored in GHCR yet. Publish ghcr.io/tryghost/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble before running E2E for this branch." | |
| exit 1 | |
| job_mirror_playwright_image: | |
| name: Mirror Playwright image | |
| runs-on: ubuntu-latest | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.is_tag != 'true' | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Login to GHCR | |
| if: needs.job_setup.outputs.playwright_image_exists != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Mirror Playwright image | |
| if: needs.job_setup.outputs.playwright_image_exists != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: ./.github/actions/mirror-container-image | |
| with: | |
| source-image: mcr.microsoft.com/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble | |
| target-image: ghcr.io/tryghost/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble | |
| - name: Fail when Playwright image cannot be mirrored | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository | |
| run: | | |
| echo "::error::Playwright v${{ needs.job_setup.outputs.playwright_version }}-noble is not mirrored in GHCR yet. Publish ghcr.io/tryghost/playwright:v${{ needs.job_setup.outputs.playwright_version }}-noble before running E2E for this branch." | |
| exit 1 |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/ci.yml around lines 244 - 275, The job
job_mirror_playwright_image must always run as a prerequisite for Playwright
consumers; move its conditional logic out of the job-level if (so the job never
gets skipped) and instead apply the mirror-only gating at the step level (the
"Login to GHCR", "Mirror Playwright image", and "Fail when Playwright image
cannot be mirrored" steps) or create a separate always-running gate job that
performs the mirror-check/mirror action, then add job_mirror_playwright_image
(or the new gate job) to the needs chain of the first Playwright-consuming job
and job_required_tests so downstream jobs cannot start until the mirror job
completes.



Summary
This updates E2E CI to mirror the pinned Playwright container image into GHCR and pull that mirrored image during test runs.
The change was motivated by intermittent CI failures when pulling directly from Microsoft Container Registry:
To reduce that flakiness, CI now:
job_setupmcr.microsoft.com/playwright:v<version>-nobletoghcr.io/tryghost/playwright:v<version>-noble