Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/actions/mirror-container-image/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: 'Mirror Container Image'
description: 'Mirror an upstream container image into GHCR'
inputs:
source-image:
description: 'Fully-qualified source image reference'
required: true
target-image:
description: 'Fully-qualified target image reference'
required: true

runs:
using: 'composite'
steps:
- name: Mirror image
shell: bash
env:
SOURCE_IMAGE: ${{ inputs.source-image }}
TARGET_IMAGE: ${{ inputs.target-image }}
run: |
set -euo pipefail

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"
Comment on lines +22 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 create makes a “carbon copy” to the destination tag. [3]

  • Skopeo (common alternative):

    skopeo copy --all docker://SRC_IMAGE:TAG docker://DEST_IMAGE:TAG

    --all ensures 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:


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.

Suggested change
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.

57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ jobs:
echo "Setting BASE_COMMIT to $BASE_COMMIT"
echo "BASE_COMMIT=$BASE_COMMIT" >> $GITHUB_ENV

- name: Fetch base commit
run: git fetch --no-tags --depth=1 origin "${BASE_COMMIT}"

- name: Check user org membership
id: check_user_org_membership
if: github.event_name == 'pull_request'
Expand Down Expand Up @@ -191,6 +194,25 @@ jobs:
- name: Install dependencies
run: bash .github/scripts/install-deps.sh

- name: Resolve Playwright image
id: playwright
run: |
set -euo pipefail

PLAYWRIGHT_VERSION="$(node -p "require('./e2e/package.json').devDependencies['@playwright/test']")"
echo "version=$PLAYWRIGHT_VERSION" >> "$GITHUB_OUTPUT"

# 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
Comment on lines +205 to +214
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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; check WWW-Authenticate). [2]
    • 403 Forbidden (authenticated but not allowed / insufficient scope). [2]
  • Rate limit / throttling: 429 Too Many Requests. [2]
  • Server-side errors: 5xx (notably 502, 503, 504 are treated as temporary/retryable per the spec; other 5xx are 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:


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.

Suggested change
# 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".



outputs:
changed_admin: ${{ steps.changed.outputs.admin }}
Expand All @@ -216,6 +238,41 @@ jobs:
dependency_cache_key: ${{ env.cachekey }}
node_version: ${{ env.NODE_VERSION }}
node_test_matrix: ${{ steps.node_matrix.outputs.matrix }}
playwright_version: ${{ steps.playwright.outputs.version }}
playwright_image_exists: ${{ steps.playwright.outputs.image-exists }}

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
Comment on lines +244 to +275
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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-image

Then 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.

Suggested change
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.


job_app_version_bump_check:
name: Check app version bump
Expand Down
51 changes: 35 additions & 16 deletions .github/workflows/cleanup-ghcr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
package: [ghost, ghost-core, ghost-development]
package: [ghost, ghost-core, ghost-development, playwright]
steps:
- name: Delete old non-release versions
env:
Expand All @@ -60,6 +60,11 @@ jobs:
if ! batch=$(gh api \
"/orgs/${ORG}/packages/container/${PACKAGE}/versions?per_page=100&page=${page}" \
--jq '.' 2>&1); then
if echo "$batch" | grep -qiE '404|package.*not found'; then
echo "::notice::Package ${ORG}/${PACKAGE} does not exist yet; skipping."
break
fi

if [ "$page" = "1" ]; then
echo "::error::API request failed: ${batch}"
exit 1
Expand All @@ -77,13 +82,15 @@ jobs:
page=$((page + 1))
done

all_versions=$(echo "$all_versions" | jq 'sort_by(.updated_at) | reverse')
total=$(echo "$all_versions" | jq 'length')
echo "Total versions: ${total}"

# Classify versions
keep=0
delete=0
delete_ids=""
index=0

for row in $(echo "$all_versions" | jq -r '.[] | @base64'); do
_jq() { echo "$row" | base64 -d | jq -r "$1"; }
Expand All @@ -92,28 +99,40 @@ jobs:
updated=$(_jq '.updated_at')
tags=$(_jq '[.metadata.container.tags[]] | join(",")')

# Keep versions with semver tags (v1.2.3, 1.2.3, 1.2)
if echo "$tags" | grep -qE '(^|,)v?[0-9]+\.[0-9]+\.[0-9]+(,|$)' || \
echo "$tags" | grep -qE '(^|,)[0-9]+\.[0-9]+(,|$)'; then
keep=$((keep + 1))
continue
fi
if [ "$PACKAGE" = "playwright" ]; then
if [ "$index" -lt "$MIN_KEEP" ] || [[ "$updated" > "$cutoff" ]]; then
keep=$((keep + 1))
index=$((index + 1))
continue
fi
else
# Keep versions with semver tags (v1.2.3, 1.2.3, 1.2)
if echo "$tags" | grep -qE '(^|,)v?[0-9]+\.[0-9]+\.[0-9]+(,|$)' || \
echo "$tags" | grep -qE '(^|,)[0-9]+\.[0-9]+(,|$)'; then
keep=$((keep + 1))
index=$((index + 1))
continue
fi

# Keep versions with 'latest' or 'main' or cache-main tags
if echo "$tags" | grep -qE '(^|,)(latest|main|cache-main)(,|$)'; then
keep=$((keep + 1))
continue
fi
# Keep versions with 'latest' or 'main' or cache-main tags
if echo "$tags" | grep -qE '(^|,)(latest|main|cache-main)(,|$)'; then
keep=$((keep + 1))
index=$((index + 1))
continue
fi

# Keep versions newer than cutoff
if [[ "$updated" > "$cutoff" ]]; then
keep=$((keep + 1))
continue
# Keep versions newer than cutoff
if [[ "$updated" > "$cutoff" ]]; then
keep=$((keep + 1))
index=$((index + 1))
continue
fi
fi

# This version is eligible for deletion
delete=$((delete + 1))
delete_ids="${delete_ids} ${id}"
index=$((index + 1))

tag_display="${tags:-<untagged>}"
if [ "$DRY_RUN" = "true" ]; then
Expand Down
2 changes: 1 addition & 1 deletion e2e/scripts/load-playwright-container-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$REPO_ROOT"

PLAYWRIGHT_VERSION="$(node -p 'require("./e2e/package.json").devDependencies["@playwright/test"]')"
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
PLAYWRIGHT_IMAGE="${PLAYWRIGHT_IMAGE:-ghcr.io/tryghost/playwright:v${PLAYWRIGHT_VERSION}-noble}"
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$REPO_ROOT}"

export SCRIPT_DIR
Expand Down
Loading