diff --git a/.github/workflows/mirror-official.yml b/.github/workflows/mirror-official.yml
new file mode 100644
index 0000000..7d4f9e0
--- /dev/null
+++ b/.github/workflows/mirror-official.yml
@@ -0,0 +1,73 @@
+name: mirror official
+
+# Copies tags of the official `plone` image (docker.io/library/plone) into
+# plone/plone and GHCR, by digest, without rebuilding them. The list of tags
+# and their pinned digests is mirror/official-tags.txt.
+#
+# Why copy rather than build: the 4.3 - 5.2 images were built and published by
+# Docker Official Images from this repository's own Dockerfiles, and those
+# Dockerfiles can no longer be rebuilt faithfully: their Debian releases have
+# left deb.debian.org, so a rebuild today either fails at apt-get or produces a
+# different image. A copy is the image that was released.
+#
+# Pull requests only verify that every source tag still resolves to its pinned
+# digest. Publishing is manual: run this workflow from `main`.
+
+on:
+ pull_request:
+ paths:
+ - 'mirror/**'
+ - '.github/workflows/mirror-official.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ mirror:
+ runs-on: ubuntu-latest
+ env:
+ # Pinned on purpose, as in legacy-build.yml: deriving it from
+ # github.repository would change the image name when the repository is
+ # renamed (see #209).
+ GHCR_IMAGE: ghcr.io/plone/plone.docker
+ DOCKERHUB_IMAGE: docker.io/plone/plone
+ PUBLISH: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && '1' || '0' }}
+ # Secrets cannot be referenced from an `if:` condition; see
+ # legacy-build.yml for the same pattern.
+ HAS_DOCKERHUB: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: docker/setup-buildx-action@v4
+
+ - name: Login to GHCR
+ if: env.PUBLISH == '1'
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to Docker Hub
+ if: env.PUBLISH == '1' && env.HAS_DOCKERHUB == 'true'
+ uses: docker/login-action@v4
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Unlike legacy-build.yml, a missing Docker Hub login fails the run
+ # instead of falling back to GHCR only: Docker Hub is the target that
+ # matters here, and a green run that skipped it would read as done.
+ - name: Mirror
+ run: |
+ if [ "${PUBLISH}" = "1" ] && [ "${HAS_DOCKERHUB}" != "true" ]; then
+ echo "::error::DOCKERHUB_USERNAME/DOCKERHUB_TOKEN not set - cannot publish to ${DOCKERHUB_IMAGE}."
+ exit 1
+ fi
+ TARGETS="${DOCKERHUB_IMAGE} ${GHCR_IMAGE}" ./mirror/mirror-official.sh
diff --git a/CHANGES.md b/CHANGES.md
index 23e466c..e5d92ca 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,4 +1,6 @@
# Changelog
+- Publish Plone 5.2.14 as `plone/plone:5.2.14` (and GHCR), copied by digest from the official `plone:5.2.14`. `plone/plone:latest`, `5`, `5.2` and `python38` now point at 5.2.14 instead of 5.2.4. Refs #210
+ [@ericof]
- Add Plone 1.0 image to the legacy matrix, completing it. Refs #197
[@ericof]
- Add Plone 2.0 image to the legacy matrix. Refs #196
diff --git a/README.md b/README.md
index 8ebfc7a..13ff2af 100644
--- a/README.md
+++ b/README.md
@@ -64,11 +64,19 @@ alone do not say which:
| Rows | Registry | Pull with |
|---|---|---|
| 4.3 - 5.2 | the official `plone` image | `docker pull plone:5.2.14` |
+| 5.2.14 (Debian) | also `plone/plone` and GHCR | `docker pull plone/plone:5.2.14`
`docker pull ghcr.io/plone/plone.docker:5.2.14` |
| `legacy/` | `plone/plone` and GHCR | `docker pull plone/plone:4.2`
`docker pull ghcr.io/plone/plone.docker:4.2` |
-`plone/plone` also carries its own 4.3 - 5.2 tags, but those are a mirror last
-pushed in **March 2021** and are older than the official image's. For anything
-4.3 and above, prefer `plone:`.
+The 5.2.14 tags on `plone/plone` and GHCR are copies of the official image, made
+by digest rather than rebuilt: `plone/plone:5.2.14` *is* `plone:5.2.14`. The
+tags copied, and the digests they are pinned to, are listed in
+[`mirror/official-tags.txt`](mirror/official-tags.txt); the
+[`mirror official`](.github/workflows/mirror-official.yml) workflow publishes
+them.
+
+`plone/plone`'s other 4.3 - 5.2 tags come from an earlier mirror, last pushed in
+**March 2021**, and lag behind the official image. For any other 4.3 - 5.2 tag,
+prefer `plone:`.
Each legacy series publishes four tags — the full version and the series, each
with a `-demo` counterpart that ships a Plone site already created.
diff --git a/mirror/mirror-official.sh b/mirror/mirror-official.sh
new file mode 100755
index 0000000..546ec5c
--- /dev/null
+++ b/mirror/mirror-official.sh
@@ -0,0 +1,62 @@
+#!/bin/sh
+# Copy tags of the official `plone` image into our own repositories.
+#
+# ./mirror/mirror-official.sh # verify only: nothing is pushed
+# PUBLISH=1 ./mirror/mirror-official.sh # verify, copy, verify the copies
+#
+# Reads ` ` pairs from mirror/official-tags.txt. Every source tag
+# must still resolve to its pinned digest before anything is copied, and every
+# copy must come out with that same digest, so a mirrored tag is provably the
+# official image and not a rebuild of it.
+#
+# The copy is `docker buildx imagetools create` from the pinned digest: a
+# registry-side manifest copy, with no pull and no build, that keeps every
+# platform of the multi-arch image.
+set -eu
+
+SOURCE="${SOURCE:-docker.io/library/plone}"
+TARGETS="${TARGETS:-docker.io/plone/plone ghcr.io/plone/plone.docker}"
+TAGS_FILE="${TAGS_FILE:-$(dirname "$0")/official-tags.txt}"
+PUBLISH="${PUBLISH:-0}"
+
+digest_of() {
+ docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1"
+}
+
+PAIRS=$(grep -vE '^[[:space:]]*(#|$)' "${TAGS_FILE}")
+COUNT=$(printf '%s\n' "${PAIRS}" | wc -l | tr -d ' ')
+if [ -z "${PAIRS}" ]; then
+ echo "FAIL: no tags listed in ${TAGS_FILE}" >&2
+ exit 1
+fi
+
+echo "=== verifying ${COUNT} source tag(s) in ${SOURCE} ==="
+printf '%s\n' "${PAIRS}" | while read -r tag digest; do
+ actual=$(digest_of "${SOURCE}:${tag}")
+ if [ "${actual}" != "${digest}" ]; then
+ echo "FAIL: ${SOURCE}:${tag} is ${actual}, pinned ${digest}" >&2
+ exit 1
+ fi
+ echo "OK: ${SOURCE}:${tag} = ${digest}"
+done
+
+if [ "${PUBLISH}" != "1" ]; then
+ echo "NOTE: PUBLISH is not 1 - verified only, nothing copied"
+ exit 0
+fi
+
+for target in ${TARGETS}; do
+ echo "=== copying ${COUNT} tag(s) to ${target} ==="
+ printf '%s\n' "${PAIRS}" | while read -r tag digest; do
+ docker buildx imagetools create \
+ --tag "${target}:${tag}" "${SOURCE}@${digest}"
+ actual=$(digest_of "${target}:${tag}")
+ if [ "${actual}" != "${digest}" ]; then
+ echo "FAIL: ${target}:${tag} came out as ${actual}, expected ${digest}" >&2
+ exit 1
+ fi
+ echo "OK: ${target}:${tag} = ${digest}"
+ done
+done
+
+echo "MIRROR PASS: ${COUNT} tag(s) x $(echo "${TARGETS}" | wc -w | tr -d ' ') target(s)"
diff --git a/mirror/official-tags.txt b/mirror/official-tags.txt
new file mode 100644
index 0000000..a508fed
--- /dev/null
+++ b/mirror/official-tags.txt
@@ -0,0 +1,21 @@
+# Tags copied from the official `plone` image (docker.io/library/plone) into
+# plone/plone and GHCR by mirror/mirror-official.sh.
+#
+# One ` ` pair per line. The digest is the official image's
+# manifest list for that tag, pinned so the copy is exactly what was reviewed:
+# if the official tag ever moves, the mirror fails instead of following it.
+#
+# Resolve a digest with:
+# docker buildx imagetools inspect --format '{{.Manifest.Digest}}' plone:
+
+# Plone 5.2.14, Python 3.8, Debian: built by Docker Official Images from
+# 5.2/5.2.14/debian at plone/plone.docker@a3a9c7e (2024-09-27). Same tag set as
+# the official image; the floating tags previously pointed at 5.2.4.
+5.2.14 sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b
+5.2.14-python38 sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b
+5.2 sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b
+5.2-python38 sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b
+5 sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b
+5-python38 sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b
+python38 sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b
+latest sha256:e73ae6e9864e43d4d49169f9fda09012d48c7720e8e204a8b0304b764600923b