From 2160c8e3c9318d4fe432fe49d802c0c9b4f3f30f Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 25 Aug 2026 21:38:01 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20initial=206-next=20docker?= =?UTF-8?q?=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://github.com/docker-library/official-images/issues/22081 - added new 6-next templates with new image structure + install mechanism --- .gitattributes | 1 + 6-next/alpine3.23/Dockerfile | 129 +++++++++++++++++ 6-next/alpine3.23/config.production.json | 22 +++ 6-next/alpine3.23/docker-entrypoint.sh | 96 +++++++++++++ 6-next/bookworm/Dockerfile | 147 +++++++++++++++++++ 6-next/bookworm/config.production.json | 22 +++ 6-next/bookworm/docker-entrypoint.sh | 96 +++++++++++++ Dockerfile-next.template | 175 +++++++++++++++++++++++ apply-templates.sh | 24 +++- config-next.json | 22 +++ docker-entrypoint-next.sh | 96 +++++++++++++ generate-stackbrew-library.jq | 17 ++- versions.json | 28 ++++ versions.sh | 111 +++++++++++--- 14 files changed, 958 insertions(+), 28 deletions(-) create mode 100644 6-next/alpine3.23/Dockerfile create mode 100644 6-next/alpine3.23/config.production.json create mode 100755 6-next/alpine3.23/docker-entrypoint.sh create mode 100644 6-next/bookworm/Dockerfile create mode 100644 6-next/bookworm/config.production.json create mode 100755 6-next/bookworm/docker-entrypoint.sh create mode 100644 Dockerfile-next.template create mode 100644 config-next.json create mode 100755 docker-entrypoint-next.sh diff --git a/.gitattributes b/.gitattributes index 9396a6be..4466d9b5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ /*/**/Dockerfile linguist-generated /*/**/docker-entrypoint.sh linguist-generated +/*/**/config.production.json linguist-generated /Dockerfile.template linguist-language=Dockerfile diff --git a/6-next/alpine3.23/Dockerfile b/6-next/alpine3.23/Dockerfile new file mode 100644 index 00000000..e50b6cd0 --- /dev/null +++ b/6-next/alpine3.23/Dockerfile @@ -0,0 +1,129 @@ +# +# NOTE: THIS DOCKERFILE IS GENERATED VIA "apply-templates.sh" +# +# PLEASE DO NOT EDIT IT DIRECTLY. +# + +# ---- build: fetch Ghost, resolve its dependency tree, compile native modules ---- +FROM node:22-alpine3.23 AS build + +# nothing here reaches the runtime stage, so there is no cleanup to do +RUN set -eux; \ + apk add --no-cache dpkg gnupg + +# grab gosu for easy step-down from root (ahead of the Ghost install so it stays cached) +# https://github.com/tianon/gosu/releases +ENV GOSU_VERSION=1.19 +RUN set -eux; \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + \ +# verify the signature + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + gpgconf --kill all; \ + rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +ENV GHOST_INSTALL=/home/ghost +ENV GHOST_VERSION=6.60.0 + +# resolved by "versions.sh" so a build cannot pick up a different tarball than the one reviewed +ENV GHOST_TARBALL=https://github.com/TryGhost/Ghost/releases/download/v6.60.0/ghost-6.60.0.tgz +ENV GHOST_SHA256=c06adeb06f902e70f7a8948deca65a5d865b9a6c0a5c1fb749b049dfc7efde68 + +RUN set -eux; \ + mkdir -p "$GHOST_INSTALL"; \ + cd "$GHOST_INSTALL"; \ + \ + wget -O ghost.tgz "$GHOST_TARBALL"; \ + echo "$GHOST_SHA256 ghost.tgz" | sha256sum -c -; \ +# the release tarball has no leading "package/" component, unlike an npm pack + tar --extract --file ghost.tgz; \ + rm ghost.tgz; \ + \ +# Ghost pins its pnpm by hash in "packageManager", so corepack fetches exactly that one + corepack enable; \ + \ +# corepack downloads and pnpm's cache/store derive their location from these; /tmp keeps them +# out of the copied tree + export XDG_CACHE_HOME=/tmp/xdg-cache XDG_DATA_HOME=/tmp/xdg-data; \ + \ +# the tarball ships a pruned lockfile, so the tree is installed exactly, not re-solved per build + pnpm install --prod --frozen-lockfile; \ + \ +# install-time inputs only: package.json points 18 deps at "file:components/*.tgz", but pnpm +# extracts them into its virtual store and nothing resolves back here afterwards + rm -rf "$GHOST_INSTALL/components"; \ + \ +# Ghost's own pruner, as used by its production image: drops dependency TypeScript, sourcemaps, +# READMEs and vendored C/C++ (keeping licences and prebuilt .node). The tarball already has the +# "archive" profile applied, so this is mostly node_modules. Node 22 strips the .mts types itself. + node scripts/prune.mts "$GHOST_INSTALL" --profile=image; \ + \ +# make a config.json symlink for NODE_ENV=development (config.production.json is copied in below) + ln -s config.production.json "$GHOST_INSTALL/config.development.json"; \ + \ +# need to save initial content for pre-seeding empty volumes + mv "$GHOST_INSTALL/content" "$GHOST_INSTALL/content.orig"; \ + mkdir -p "$GHOST_INSTALL/content"; \ + \ +# test that the optional dependencies are installed and loadable + node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");' + +# ---- runtime ---- +FROM alpine:3.23 + +RUN set -eux; \ +# libstdc++ is what node itself links against, plus better-sqlite3 and sharp + apk add --no-cache ca-certificates libstdc++; \ + \ +# uid 1000 is unclaimed on this base and matches what the CLI-based image ran as, so existing bind +# mounts keep working. No home dir: the COPY below creates it, without /etc/skel's dotfiles + addgroup -g 1000 ghost; \ + adduser -u 1000 -G ghost -h /home/ghost -s /bin/sh -H -D ghost + +# node, npm, npx, corepack and gosu all live here. "corepack enable" in the build stage repointed +# the yarn symlinks at corepack instead of the standalone yarn under /opt, so this tree resolves on +# its own. Keeping npm/corepack matches the tooling the Ghost-CLI image exposed. +# (node's own docker-entrypoint.sh tags along; ours is copied over it below.) +COPY --from=build /usr/local /usr/local + +ENV NODE_ENV=production + +ENV GHOST_INSTALL=/home/ghost +ENV GHOST_CONTENT=/home/ghost/content + +ENV GHOST_VERSION=6.60.0 + +COPY --from=build --chown=ghost:ghost /home/ghost /home/ghost + +# Ghost-CLI used to generate this. Everything in it is overridable via Ghost's "__" env vars, and +# "process" is deliberately absent -- it only ever told Ghost-CLI which process manager to use +COPY --chown=ghost:ghost config.production.json $GHOST_INSTALL/ + +RUN set -eux; \ +# a mount point, so it stays writable whatever uid ends up owning what gets mounted over it + chmod 1777 "$GHOST_CONTENT"; \ + \ +# node --version catches a missing libstdc++/libatomic; the module loads prove the copied tree +# still resolves outside the build stage + node --version; \ + cd "$GHOST_INSTALL"; \ + node -e 'JSON.parse(require("fs").readFileSync("config.production.json"))'; \ + [ "$(readlink -f config.development.json)" = "$GHOST_INSTALL/config.production.json" ]; \ + gosu ghost node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");' + +WORKDIR $GHOST_INSTALL +VOLUME $GHOST_CONTENT + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 2368 +CMD ["node", "index.js"] diff --git a/6-next/alpine3.23/config.production.json b/6-next/alpine3.23/config.production.json new file mode 100644 index 00000000..31ec76a8 --- /dev/null +++ b/6-next/alpine3.23/config.production.json @@ -0,0 +1,22 @@ +{ + "url": "http://localhost:2368", + "server": { + "port": 2368, + "host": "::" + }, + "mail": { + "transport": "Direct" + }, + "logging": { + "transports": [ + "file", + "stdout" + ] + }, + "security": { + "staffDeviceVerification": true + }, + "paths": { + "contentPath": "/home/ghost/content" + } +} diff --git a/6-next/alpine3.23/docker-entrypoint.sh b/6-next/alpine3.23/docker-entrypoint.sh new file mode 100755 index 00000000..bafa8d6b --- /dev/null +++ b/6-next/alpine3.23/docker-entrypoint.sh @@ -0,0 +1,96 @@ +#!/bin/sh +set -e + +# POSIX sh, not bash: avoids an Alpine bash package just for "[[ ... ]]" +case "$*" in + node*index.js*) isGhost=1 ;; + *) isGhost= ;; +esac + +# ghost:6 installed to /var/lib/ghost; this image uses /home/ghost. An old -v target would boot an +# empty site while the real content sat unread, so: content there is fatal, an empty mount warns. +OLD_INSTALL='/var/lib/ghost' +OLD_CONTENT="$OLD_INSTALL/content" + +_is_mountpoint() { + # BusyBox "mountpoint" compares only dev/ino, so it misses bind mounts -- i.e. nearly every + # Docker volume, and exactly what is being looked for here. Fall back to the mount table. + # https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh + mountpoint -q "$1" 2>/dev/null \ + || awk -v dir="$1" '$5 == dir { found = 1 } END { exit !found }' /proc/self/mountinfo 2>/dev/null +} + +_has_ghost_content() { + [ -d "$1" ] || return 1 + # any of these means a real site, not the empty dir Docker creates for a mount + for marker in "$1"/data/*.db "$1"/settings/routes.yaml "$1"/images/* "$1"/themes/*; do + if [ -e "$marker" ]; then + return 0 + fi + done + return 1 +} + +# the root pass below re-execs through gosu, so a marker keeps these from printing twice +if [ -n "$isGhost" ] && [ -z "${GHOST_OLD_PATH_CHECKED:-}" ]; then + export GHOST_OLD_PATH_CHECKED=1 + + if _has_ghost_content "$OLD_CONTENT" || [ -e "$OLD_INSTALL/.ghost-cli" ]; then + cat >&2 <<-EOE + Error: found existing Ghost content at $OLD_CONTENT, but this image stores + Ghost in /home/ghost. + + ghost:6 and earlier installed to /var/lib/ghost. This tag moves that to + /home/ghost, so the content mounted at the old path is not being read. + Starting would create an empty site and leave that content untouched, so + this container will not start. + + Point the mount at the new location instead: + + -v ghost_content:/home/ghost/content + + rather than: + + -v ghost_content:$OLD_CONTENT + + Nothing at $OLD_INSTALL has been modified. + EOE + exit 1 + fi + + oldMount= + if _is_mountpoint "$OLD_CONTENT"; then + oldMount="$OLD_CONTENT" + elif _is_mountpoint "$OLD_INSTALL"; then + oldMount="$OLD_INSTALL" + fi + if [ -n "$oldMount" ]; then + cat >&2 <<-EOW + Warning: something is mounted at $oldMount, but this image stores Ghost in + /home/ghost, so that mount is unused. + + It looks empty, so this is most likely a stale -v left over from ghost:6. + Move it to /home/ghost/content. + EOW + fi +fi + +# allow the container to be started with `--user` +if [ -n "$isGhost" ] && [ "$(id -u)" = '0' ]; then + find "$GHOST_CONTENT" \! -user ghost -exec chown ghost '{}' + + exec gosu ghost "$0" "$@" +fi + +if [ -n "$isGhost" ]; then + baseDir="$GHOST_INSTALL/content.orig" + for src in "$baseDir"/*/ "$baseDir"/themes/*; do + src="${src%/}" + target="$GHOST_CONTENT/${src#$baseDir/}" + mkdir -p "$(dirname "$target")" + if [ ! -e "$target" ]; then + tar -cC "$(dirname "$src")" "$(basename "$src")" | tar -xC "$(dirname "$target")" + fi + done +fi + +exec "$@" diff --git a/6-next/bookworm/Dockerfile b/6-next/bookworm/Dockerfile new file mode 100644 index 00000000..855f73e3 --- /dev/null +++ b/6-next/bookworm/Dockerfile @@ -0,0 +1,147 @@ +# +# NOTE: THIS DOCKERFILE IS GENERATED VIA "apply-templates.sh" +# +# PLEASE DO NOT EDIT IT DIRECTLY. +# + +# ---- build: fetch Ghost, resolve its dependency tree, compile native modules ---- +FROM node:22-bookworm-slim AS build + +# nothing here reaches the runtime stage, so there is no cleanup to do +RUN set -eux; \ + apt-get update; \ +# unlike the Alpine image, "node:*-slim" ships neither wget nor ca-certificates + apt-get install -y --no-install-recommends \ + ca-certificates \ + gnupg \ + wget \ +# only arm32v7 needs these: it has no prebuilt better-sqlite3 or re2, so they fall back to node-gyp + g++ \ + make \ + python3 \ + ; \ + rm -rf /var/lib/apt/lists/* + +# grab gosu for easy step-down from root (ahead of the Ghost install so it stays cached) +# https://github.com/tianon/gosu/releases +ENV GOSU_VERSION=1.19 +RUN set -eux; \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + \ +# verify the signature + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + gpgconf --kill all; \ + rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +ENV GHOST_INSTALL=/home/ghost +ENV GHOST_VERSION=6.60.0 + +# resolved by "versions.sh" so a build cannot pick up a different tarball than the one reviewed +ENV GHOST_TARBALL=https://github.com/TryGhost/Ghost/releases/download/v6.60.0/ghost-6.60.0.tgz +ENV GHOST_SHA256=c06adeb06f902e70f7a8948deca65a5d865b9a6c0a5c1fb749b049dfc7efde68 + +RUN set -eux; \ + mkdir -p "$GHOST_INSTALL"; \ + cd "$GHOST_INSTALL"; \ + \ + wget -O ghost.tgz "$GHOST_TARBALL"; \ + echo "$GHOST_SHA256 ghost.tgz" | sha256sum -c -; \ +# the release tarball has no leading "package/" component, unlike an npm pack + tar --extract --file ghost.tgz; \ + rm ghost.tgz; \ + \ +# Ghost pins its pnpm by hash in "packageManager", so corepack fetches exactly that one + corepack enable; \ + \ +# corepack downloads and pnpm's cache/store derive their location from these; /tmp keeps them +# out of the copied tree + export XDG_CACHE_HOME=/tmp/xdg-cache XDG_DATA_HOME=/tmp/xdg-data; \ + \ +# the tarball ships a pruned lockfile, so the tree is installed exactly, not re-solved per build + pnpm install --prod --frozen-lockfile; \ + \ +# install-time inputs only: package.json points 18 deps at "file:components/*.tgz", but pnpm +# extracts them into its virtual store and nothing resolves back here afterwards + rm -rf "$GHOST_INSTALL/components"; \ + \ +# Ghost's own pruner, as used by its production image: drops dependency TypeScript, sourcemaps, +# READMEs and vendored C/C++ (keeping licences and prebuilt .node). The tarball already has the +# "archive" profile applied, so this is mostly node_modules. Node 22 strips the .mts types itself. + node scripts/prune.mts "$GHOST_INSTALL" --profile=image; \ + \ +# make a config.json symlink for NODE_ENV=development (config.production.json is copied in below) + ln -s config.production.json "$GHOST_INSTALL/config.development.json"; \ + \ +# need to save initial content for pre-seeding empty volumes + mv "$GHOST_INSTALL/content" "$GHOST_INSTALL/content.orig"; \ + mkdir -p "$GHOST_INSTALL/content"; \ + \ +# test that the optional dependencies are installed and loadable + node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");' + +# ---- runtime ---- +FROM debian:bookworm-slim + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ +# libstdc++6 is what node itself links against, plus better-sqlite3 and sharp; libatomic1 is not +# needed on every arch, but node's own image installs it unconditionally + libstdc++6 \ + libatomic1 \ + ca-certificates \ + ; \ + rm -rf /var/lib/apt/lists/*; \ + \ +# uid 1000 is unclaimed on this base and matches what the CLI-based image ran as, so existing bind +# mounts keep working. No home dir: the COPY below creates it, without /etc/skel's dotfiles + groupadd --gid 1000 ghost; \ + useradd --uid 1000 --gid 1000 --home-dir /home/ghost --no-create-home --shell /bin/bash ghost + +# node, npm, npx, corepack and gosu all live here. "corepack enable" in the build stage repointed +# the yarn symlinks at corepack instead of the standalone yarn under /opt, so this tree resolves on +# its own. Keeping npm/corepack matches the tooling the Ghost-CLI image exposed. +# (node's own docker-entrypoint.sh tags along; ours is copied over it below.) +COPY --from=build /usr/local /usr/local + +ENV NODE_ENV=production + +ENV GHOST_INSTALL=/home/ghost +ENV GHOST_CONTENT=/home/ghost/content + +ENV GHOST_VERSION=6.60.0 + +COPY --from=build --chown=ghost:ghost /home/ghost /home/ghost + +# Ghost-CLI used to generate this. Everything in it is overridable via Ghost's "__" env vars, and +# "process" is deliberately absent -- it only ever told Ghost-CLI which process manager to use +COPY --chown=ghost:ghost config.production.json $GHOST_INSTALL/ + +RUN set -eux; \ +# a mount point, so it stays writable whatever uid ends up owning what gets mounted over it + chmod 1777 "$GHOST_CONTENT"; \ + \ +# node --version catches a missing libstdc++/libatomic; the module loads prove the copied tree +# still resolves outside the build stage + node --version; \ + cd "$GHOST_INSTALL"; \ + node -e 'JSON.parse(require("fs").readFileSync("config.production.json"))'; \ + [ "$(readlink -f config.development.json)" = "$GHOST_INSTALL/config.production.json" ]; \ + gosu ghost node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");' + +WORKDIR $GHOST_INSTALL +VOLUME $GHOST_CONTENT + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 2368 +CMD ["node", "index.js"] diff --git a/6-next/bookworm/config.production.json b/6-next/bookworm/config.production.json new file mode 100644 index 00000000..31ec76a8 --- /dev/null +++ b/6-next/bookworm/config.production.json @@ -0,0 +1,22 @@ +{ + "url": "http://localhost:2368", + "server": { + "port": 2368, + "host": "::" + }, + "mail": { + "transport": "Direct" + }, + "logging": { + "transports": [ + "file", + "stdout" + ] + }, + "security": { + "staffDeviceVerification": true + }, + "paths": { + "contentPath": "/home/ghost/content" + } +} diff --git a/6-next/bookworm/docker-entrypoint.sh b/6-next/bookworm/docker-entrypoint.sh new file mode 100755 index 00000000..bafa8d6b --- /dev/null +++ b/6-next/bookworm/docker-entrypoint.sh @@ -0,0 +1,96 @@ +#!/bin/sh +set -e + +# POSIX sh, not bash: avoids an Alpine bash package just for "[[ ... ]]" +case "$*" in + node*index.js*) isGhost=1 ;; + *) isGhost= ;; +esac + +# ghost:6 installed to /var/lib/ghost; this image uses /home/ghost. An old -v target would boot an +# empty site while the real content sat unread, so: content there is fatal, an empty mount warns. +OLD_INSTALL='/var/lib/ghost' +OLD_CONTENT="$OLD_INSTALL/content" + +_is_mountpoint() { + # BusyBox "mountpoint" compares only dev/ino, so it misses bind mounts -- i.e. nearly every + # Docker volume, and exactly what is being looked for here. Fall back to the mount table. + # https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh + mountpoint -q "$1" 2>/dev/null \ + || awk -v dir="$1" '$5 == dir { found = 1 } END { exit !found }' /proc/self/mountinfo 2>/dev/null +} + +_has_ghost_content() { + [ -d "$1" ] || return 1 + # any of these means a real site, not the empty dir Docker creates for a mount + for marker in "$1"/data/*.db "$1"/settings/routes.yaml "$1"/images/* "$1"/themes/*; do + if [ -e "$marker" ]; then + return 0 + fi + done + return 1 +} + +# the root pass below re-execs through gosu, so a marker keeps these from printing twice +if [ -n "$isGhost" ] && [ -z "${GHOST_OLD_PATH_CHECKED:-}" ]; then + export GHOST_OLD_PATH_CHECKED=1 + + if _has_ghost_content "$OLD_CONTENT" || [ -e "$OLD_INSTALL/.ghost-cli" ]; then + cat >&2 <<-EOE + Error: found existing Ghost content at $OLD_CONTENT, but this image stores + Ghost in /home/ghost. + + ghost:6 and earlier installed to /var/lib/ghost. This tag moves that to + /home/ghost, so the content mounted at the old path is not being read. + Starting would create an empty site and leave that content untouched, so + this container will not start. + + Point the mount at the new location instead: + + -v ghost_content:/home/ghost/content + + rather than: + + -v ghost_content:$OLD_CONTENT + + Nothing at $OLD_INSTALL has been modified. + EOE + exit 1 + fi + + oldMount= + if _is_mountpoint "$OLD_CONTENT"; then + oldMount="$OLD_CONTENT" + elif _is_mountpoint "$OLD_INSTALL"; then + oldMount="$OLD_INSTALL" + fi + if [ -n "$oldMount" ]; then + cat >&2 <<-EOW + Warning: something is mounted at $oldMount, but this image stores Ghost in + /home/ghost, so that mount is unused. + + It looks empty, so this is most likely a stale -v left over from ghost:6. + Move it to /home/ghost/content. + EOW + fi +fi + +# allow the container to be started with `--user` +if [ -n "$isGhost" ] && [ "$(id -u)" = '0' ]; then + find "$GHOST_CONTENT" \! -user ghost -exec chown ghost '{}' + + exec gosu ghost "$0" "$@" +fi + +if [ -n "$isGhost" ]; then + baseDir="$GHOST_INSTALL/content.orig" + for src in "$baseDir"/*/ "$baseDir"/themes/*; do + src="${src%/}" + target="$GHOST_CONTENT/${src#$baseDir/}" + mkdir -p "$(dirname "$target")" + if [ ! -e "$target" ]; then + tar -cC "$(dirname "$src")" "$(basename "$src")" | tar -xC "$(dirname "$target")" + fi + done +fi + +exec "$@" diff --git a/Dockerfile-next.template b/Dockerfile-next.template new file mode 100644 index 00000000..6e8cb69d --- /dev/null +++ b/Dockerfile-next.template @@ -0,0 +1,175 @@ +{{ + def is_alpine: + env.variant | startswith("alpine") + ; + def clean_apt: + # TODO once bookworm is EOL, remove this and just hard-code "apt-get dist-clean" instead + if env.variant | contains("bookworm") then + "rm -rf /var/lib/apt/lists/*" + else "apt-get dist-clean" end + ; + # the runtime stage starts from the distro under "node:*", not node itself: node ships a "node" + # user at 1000:1000 and Ghost wants that uid, so this way there is no user to renumber (which + # Alpine could not do anyway -- no usermod/groupmod) + def runtime_base: + if is_alpine then + env.variant | sub("^alpine"; "alpine:") + else + "debian:\(env.variant)-slim" + end +-}} +# ---- build: fetch Ghost, resolve its dependency tree, compile native modules ---- +FROM {{ .variants[env.variant].from }} AS build + +# nothing here reaches the runtime stage, so there is no cleanup to do +RUN set -eux; \ +{{ if is_alpine then ( -}} + apk add --no-cache dpkg gnupg +{{ ) else ( -}} + apt-get update; \ +# unlike the Alpine image, "node:*-slim" ships neither wget nor ca-certificates + apt-get install -y --no-install-recommends \ + ca-certificates \ + gnupg \ + wget \ +# only arm32v7 needs these: it has no prebuilt better-sqlite3 or re2, so they fall back to node-gyp + g++ \ + make \ + python3 \ + ; \ + {{ clean_apt }} +{{ ) end -}} + +# grab gosu for easy step-down from root (ahead of the Ghost install so it stays cached) +# https://github.com/tianon/gosu/releases +ENV GOSU_VERSION=1.19 +RUN set -eux; \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + \ +# verify the signature + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + gpgconf --kill all; \ + rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +ENV GHOST_INSTALL=/home/ghost +ENV GHOST_VERSION={{ .version }} + +# resolved by "versions.sh" so a build cannot pick up a different tarball than the one reviewed +ENV GHOST_TARBALL={{ .tarball.url }} +ENV GHOST_SHA256={{ .tarball.sha256 }} + +RUN set -eux; \ + mkdir -p "$GHOST_INSTALL"; \ + cd "$GHOST_INSTALL"; \ + \ + wget -O ghost.tgz "$GHOST_TARBALL"; \ + echo "$GHOST_SHA256 ghost.tgz" | sha256sum -c -; \ +# the release tarball has no leading "package/" component, unlike an npm pack + tar --extract --file ghost.tgz; \ + rm ghost.tgz; \ + \ +# Ghost pins its pnpm by hash in "packageManager", so corepack fetches exactly that one + corepack enable; \ + \ +# corepack downloads and pnpm's cache/store derive their location from these; /tmp keeps them +# out of the copied tree + export XDG_CACHE_HOME=/tmp/xdg-cache XDG_DATA_HOME=/tmp/xdg-data; \ + \ +# the tarball ships a pruned lockfile, so the tree is installed exactly, not re-solved per build + pnpm install --prod --frozen-lockfile; \ + \ +# install-time inputs only: package.json points 18 deps at "file:components/*.tgz", but pnpm +# extracts them into its virtual store and nothing resolves back here afterwards + rm -rf "$GHOST_INSTALL/components"; \ + \ +# Ghost's own pruner, as used by its production image: drops dependency TypeScript, sourcemaps, +# READMEs and vendored C/C++ (keeping licences and prebuilt .node). The tarball already has the +# "archive" profile applied, so this is mostly node_modules. Node 22 strips the .mts types itself. + node scripts/prune.mts "$GHOST_INSTALL" --profile=image; \ + \ +# make a config.json symlink for NODE_ENV=development (config.production.json is copied in below) + ln -s config.production.json "$GHOST_INSTALL/config.development.json"; \ + \ +# need to save initial content for pre-seeding empty volumes + mv "$GHOST_INSTALL/content" "$GHOST_INSTALL/content.orig"; \ + mkdir -p "$GHOST_INSTALL/content"; \ + \ +# test that the optional dependencies are installed and loadable + node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");' + +# ---- runtime ---- +FROM {{ runtime_base }} + +RUN set -eux; \ +{{ if is_alpine then ( -}} +# libstdc++ is what node itself links against, plus better-sqlite3 and sharp + apk add --no-cache ca-certificates libstdc++; \ +{{ ) else ( -}} + apt-get update; \ + apt-get install -y --no-install-recommends \ +# libstdc++6 is what node itself links against, plus better-sqlite3 and sharp; libatomic1 is not +# needed on every arch, but node's own image installs it unconditionally + libstdc++6 \ + libatomic1 \ + ca-certificates \ + ; \ + {{ clean_apt }}; \ +{{ ) end -}} + \ +# uid 1000 is unclaimed on this base and matches what the CLI-based image ran as, so existing bind +# mounts keep working. No home dir: the COPY below creates it, without /etc/skel's dotfiles +{{ if is_alpine then ( -}} + addgroup -g 1000 ghost; \ + adduser -u 1000 -G ghost -h /home/ghost -s /bin/sh -H -D ghost +{{ ) else ( -}} + groupadd --gid 1000 ghost; \ + useradd --uid 1000 --gid 1000 --home-dir /home/ghost --no-create-home --shell /bin/bash ghost +{{ ) end -}} + +# node, npm, npx, corepack and gosu all live here. "corepack enable" in the build stage repointed +# the yarn symlinks at corepack instead of the standalone yarn under /opt, so this tree resolves on +# its own. Keeping npm/corepack matches the tooling the Ghost-CLI image exposed. +# (node's own docker-entrypoint.sh tags along; ours is copied over it below.) +COPY --from=build /usr/local /usr/local + +ENV NODE_ENV=production + +ENV GHOST_INSTALL=/home/ghost +ENV GHOST_CONTENT=/home/ghost/content + +ENV GHOST_VERSION={{ .version }} + +COPY --from=build --chown=ghost:ghost /home/ghost /home/ghost + +# Ghost-CLI used to generate this. Everything in it is overridable via Ghost's "__" env vars, and +# "process" is deliberately absent -- it only ever told Ghost-CLI which process manager to use +COPY --chown=ghost:ghost config.production.json $GHOST_INSTALL/ + +RUN set -eux; \ +# a mount point, so it stays writable whatever uid ends up owning what gets mounted over it + chmod 1777 "$GHOST_CONTENT"; \ + \ +# node --version catches a missing libstdc++/libatomic; the module loads prove the copied tree +# still resolves outside the build stage + node --version; \ + cd "$GHOST_INSTALL"; \ + node -e 'JSON.parse(require("fs").readFileSync("config.production.json"))'; \ + [ "$(readlink -f config.development.json)" = "$GHOST_INSTALL/config.production.json" ]; \ + gosu ghost node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");' + +WORKDIR $GHOST_INSTALL +VOLUME $GHOST_CONTENT + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 2368 +CMD ["node", "index.js"] diff --git a/apply-templates.sh b/apply-templates.sh index b9861298..a23c10cf 100755 --- a/apply-templates.sh +++ b/apply-templates.sh @@ -42,6 +42,23 @@ for version; do variants="$(jq -r '.[env.version].variants | keys_unsorted | map(@sh) | join(" ")' versions.json)" eval "variants=( $variants )" + # "-next" has its own template: multi-stage, no Ghost-CLI, install at /home/ghost, and a + # checked-in baseline config (Ghost-CLI used to generate one) + template='Dockerfile.template' + entrypoint='docker-entrypoint.sh' + config= + if [ "$version" != "${version%-next}" ]; then + template='Dockerfile-next.template' + entrypoint='docker-entrypoint-next.sh' + config='config-next.json' + fi + for f in "$template" "$entrypoint" ${config:+"$config"}; do + if [ ! -f "$f" ]; then + echo >&2 "error: '$version' needs '$f', which does not exist yet" + exit 1 + fi + done + for variant in "${variants[@]}"; do export variant @@ -52,9 +69,12 @@ for version; do { generated_warning - gawk -f "$jqt" Dockerfile.template + gawk -f "$jqt" "$template" } > "$dir/Dockerfile" - cp -a docker-entrypoint.sh "$dir/" + cp -a "$entrypoint" "$dir/docker-entrypoint.sh" + if [ -n "$config" ]; then + cp -a "$config" "$dir/config.production.json" + fi done done diff --git a/config-next.json b/config-next.json new file mode 100644 index 00000000..31ec76a8 --- /dev/null +++ b/config-next.json @@ -0,0 +1,22 @@ +{ + "url": "http://localhost:2368", + "server": { + "port": 2368, + "host": "::" + }, + "mail": { + "transport": "Direct" + }, + "logging": { + "transports": [ + "file", + "stdout" + ] + }, + "security": { + "staffDeviceVerification": true + }, + "paths": { + "contentPath": "/home/ghost/content" + } +} diff --git a/docker-entrypoint-next.sh b/docker-entrypoint-next.sh new file mode 100755 index 00000000..bafa8d6b --- /dev/null +++ b/docker-entrypoint-next.sh @@ -0,0 +1,96 @@ +#!/bin/sh +set -e + +# POSIX sh, not bash: avoids an Alpine bash package just for "[[ ... ]]" +case "$*" in + node*index.js*) isGhost=1 ;; + *) isGhost= ;; +esac + +# ghost:6 installed to /var/lib/ghost; this image uses /home/ghost. An old -v target would boot an +# empty site while the real content sat unread, so: content there is fatal, an empty mount warns. +OLD_INSTALL='/var/lib/ghost' +OLD_CONTENT="$OLD_INSTALL/content" + +_is_mountpoint() { + # BusyBox "mountpoint" compares only dev/ino, so it misses bind mounts -- i.e. nearly every + # Docker volume, and exactly what is being looked for here. Fall back to the mount table. + # https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh + mountpoint -q "$1" 2>/dev/null \ + || awk -v dir="$1" '$5 == dir { found = 1 } END { exit !found }' /proc/self/mountinfo 2>/dev/null +} + +_has_ghost_content() { + [ -d "$1" ] || return 1 + # any of these means a real site, not the empty dir Docker creates for a mount + for marker in "$1"/data/*.db "$1"/settings/routes.yaml "$1"/images/* "$1"/themes/*; do + if [ -e "$marker" ]; then + return 0 + fi + done + return 1 +} + +# the root pass below re-execs through gosu, so a marker keeps these from printing twice +if [ -n "$isGhost" ] && [ -z "${GHOST_OLD_PATH_CHECKED:-}" ]; then + export GHOST_OLD_PATH_CHECKED=1 + + if _has_ghost_content "$OLD_CONTENT" || [ -e "$OLD_INSTALL/.ghost-cli" ]; then + cat >&2 <<-EOE + Error: found existing Ghost content at $OLD_CONTENT, but this image stores + Ghost in /home/ghost. + + ghost:6 and earlier installed to /var/lib/ghost. This tag moves that to + /home/ghost, so the content mounted at the old path is not being read. + Starting would create an empty site and leave that content untouched, so + this container will not start. + + Point the mount at the new location instead: + + -v ghost_content:/home/ghost/content + + rather than: + + -v ghost_content:$OLD_CONTENT + + Nothing at $OLD_INSTALL has been modified. + EOE + exit 1 + fi + + oldMount= + if _is_mountpoint "$OLD_CONTENT"; then + oldMount="$OLD_CONTENT" + elif _is_mountpoint "$OLD_INSTALL"; then + oldMount="$OLD_INSTALL" + fi + if [ -n "$oldMount" ]; then + cat >&2 <<-EOW + Warning: something is mounted at $oldMount, but this image stores Ghost in + /home/ghost, so that mount is unused. + + It looks empty, so this is most likely a stale -v left over from ghost:6. + Move it to /home/ghost/content. + EOW + fi +fi + +# allow the container to be started with `--user` +if [ -n "$isGhost" ] && [ "$(id -u)" = '0' ]; then + find "$GHOST_CONTENT" \! -user ghost -exec chown ghost '{}' + + exec gosu ghost "$0" "$@" +fi + +if [ -n "$isGhost" ]; then + baseDir="$GHOST_INSTALL/content.orig" + for src in "$baseDir"/*/ "$baseDir"/themes/*; do + src="${src%/}" + target="$GHOST_CONTENT/${src#$baseDir/}" + mkdir -p "$(dirname "$target")" + if [ ! -e "$target" ]; then + tar -cC "$(dirname "$src")" "$(basename "$src")" | tar -xC "$(dirname "$target")" + fi + done +fi + +exec "$@" diff --git a/generate-stackbrew-library.jq b/generate-stackbrew-library.jq index 903af88c..aa8f3cbc 100644 --- a/generate-stackbrew-library.jq +++ b/generate-stackbrew-library.jq @@ -1,6 +1,12 @@ +# "-rc" and "-next" are pseudo-majors: they ride along with an existing major rather than being one, +# so neither can ever win "latest", and each gets its own tag shape below +def isRc: endswith("-rc"); +def isNext: endswith("-next"); +def isPseudoMajor: isRc or isNext; + to_entries # latest version is the first one since it is already in version order -| first(.[] | select(.value and (.key | endswith("-rc") | not))) as $latestMajor +| first(.[] | select(.value and (.key | isPseudoMajor | not))) as $latestMajor # latest suite of each variant in the latest version | first($latestMajor.value.variants | keys_unsorted[] | select(contains("alpine"))) as $latestAlpine | first($latestMajor.value.variants | keys_unsorted[] | select(contains("alpine") | not)) as $latestDebian @@ -22,15 +28,19 @@ else . end | ( .version # this is an RC, so don't explode the version - | if $majorVersion | endswith("-rc") then + | if $majorVersion | isRc then [ ., $majorVersion ] else split(".") # TODO if more than one minor of a major version is tracked (6.1 and 6.0), then this needs to be filtered when not the latest (so only one would get `6`) | [ foreach .[] as $c ([]; .+=[$c]) | join(".") ] | reverse + # "-next" explodes like a normal version, but every tag carries the suffix, plus a bare "next" + # e.g. "6.57.1-next", "6.57-next", "6-next", "next" + | if $majorVersion | isNext then + map(. + "-next") + [ "next" ] # add plain variant/latest if this is the newest version - | if $majorVersion == $latestMajor then + elif $majorVersion == $latestMajor then . + [ "" ] # empty for plain variant tag and latest else . end end @@ -73,5 +83,6 @@ else . end "Tags: \($tags | join(", "))", "Directory: \($majorVersion)/\($variant)", "Architectures: \(.arches - (.arches - $parentArches[.from]) | join(", "))", + (if $majorVersion | isNext then "Builder: buildkit" else empty end), empty ) diff --git a/versions.json b/versions.json index ed0c95ca..f450e7e6 100644 --- a/versions.json +++ b/versions.json @@ -26,5 +26,33 @@ "from": "node:22-alpine3.23" } } + }, + "6-next": { + "version": "6.60.0", + "tarball": { + "url": "https://github.com/TryGhost/Ghost/releases/download/v6.60.0/ghost-6.60.0.tgz", + "sha256": "c06adeb06f902e70f7a8948deca65a5d865b9a6c0a5c1fb749b049dfc7efde68" + }, + "node": { + "version": "22" + }, + "variants": { + "bookworm": { + "arches": [ + "amd64", + "arm32v7", + "arm64v8", + "s390x" + ], + "from": "node:22-bookworm-slim" + }, + "alpine3.23": { + "arches": [ + "amd64", + "arm64v8" + ], + "from": "node:22-alpine3.23" + } + } } } diff --git a/versions.sh b/versions.sh index 2641485e..a9a1d075 100755 --- a/versions.sh +++ b/versions.sh @@ -50,8 +50,29 @@ for version in "${versions[@]}"; do fi rcGrepV+=' -E' rcGrepExpr='alpha|beta|rc' + + # "-next" previews the Ghost 7.0 image structure (no Ghost-CLI, install at /home/ghost) but + # tracks the same stable upstream releases as its plain major, so it is stripped after the + # prerelease decision above rather than before it + nextVersion="${rcVersion%-next}" + isNext= + if [ "$nextVersion" != "$rcVersion" ]; then + isNext=1 + fi + rcVersion="$nextVersion" + export version + # https://docs.ghost.org/faq/node-versions + # https://github.com/nodejs/Release (looking for "LTS") + case "$rcVersion" in + 6) nodeVersion='22' ;; + *) + echo >&2 "error: unknown node version for '$version'" + exit 1 + ;; + esac + fullVersion="$( echo "$allVersions" \ | grep -E "^${rcVersion}([.-]|$)" \ @@ -99,28 +120,64 @@ for version in "${versions[@]}"; do ' )" - export fullVersion cliVersion cliSha - json="$(jq <<<"$json" --compact-output --argjson doc "$doc" ' - { - # https://docs.ghost.org/faq/node-versions - # https://github.com/nodejs/Release (looking for "LTS") - "6": "22", - }[env.version] as $nodeVersion - | .[env.version] = { - version: env.fullVersion, - cli: { version: env.cliVersion, sha: env.cliSha }, - node: { version: $nodeVersion }, - variants: ( - $doc - | with_entries( - # add image FROM for Dockerfile template and parent arch lookup in generate-stackbrew-library.sh - # e.g. "node:22-alpine3.23" or "node:22-trixie-slim" - .value.from = "node:\($nodeVersion)-\(.key)\( - if .key | startswith("alpine") then "" else "-slim" end - )" - ) - ), - } + # the "-next" image installs Ghost from the release tarball attached to the GitHub tag instead of + # via Ghost-CLI, so pin the exact artifact and its hash rather than re-resolving at build time. + # These assets start at 6.60.0; anything older has no tarball to install from. + if [ -n "$isNext" ]; then + tarballName="ghost-$fullVersion.tgz" + releaseJson="$(curl -fsSL "https://api.github.com/repos/TryGhost/Ghost/releases/tags/v$fullVersion")" + tarballUrl="$(jq <<<"$releaseJson" --raw-output --arg name "$tarballName" ' + .assets[]? | select(.name == $name) | .browser_download_url // empty + ')" + tarballDigest="$(jq <<<"$releaseJson" --raw-output --arg name "$tarballName" ' + .assets[]? | select(.name == $name) | .digest // empty + ')" + if [ -z "$tarballUrl" ]; then + echo >&2 "error: the GitHub release for 'v$fullVersion' has no '$tarballName' asset (these start at 6.60.0)" + exit 1 + fi + + # GitHub reports the asset digest as "sha256:"; refuse anything else rather than + # writing a hash the Dockerfile would then check with the wrong algorithm + case "$tarballDigest" in + sha256:?*) tarballSha256="${tarballDigest#sha256:}" ;; + *) + echo >&2 "error: unexpected digest '$tarballDigest' for '$tarballName'; update versions.sh" + exit 1 + ;; + esac + + sourceJson="$(jq --null-input --compact-output \ + --arg url "$tarballUrl" \ + --arg sha256 "$tarballSha256" \ + '{ tarball: { url: $url, sha256: $sha256 } }')" + else + sourceJson="$(jq --null-input --compact-output \ + --arg version "$cliVersion" \ + --arg sha "$cliSha" \ + '{ cli: { version: $version, sha: $sha } }')" + fi + + export fullVersion nodeVersion + json="$(jq <<<"$json" --compact-output --argjson doc "$doc" --argjson source "$sourceJson" ' + env.nodeVersion as $nodeVersion + | .[env.version] = ( + { version: env.fullVersion } + + $source + + { + node: { version: $nodeVersion }, + variants: ( + $doc + | with_entries( + # add image FROM for Dockerfile template and parent arch lookup in generate-stackbrew-library.sh + # e.g. "node:22-alpine3.23" or "node:22-trixie-slim" + .value.from = "node:\($nodeVersion)-\(.key)\( + if .key | startswith("alpine") then "" else "-slim" end + )" + ) + ), + } + ) ')" done @@ -128,7 +185,15 @@ jq <<<"$json" ' to_entries # sort by version number, descending - | sort_by(.value.version | split("[.-]"; "") | map(tonumber? // .)) + | sort_by([ + (.value.version | split("[.-]"; "") | map(tonumber? // .)), + + # a pseudo-major ("6-next") tracks the same upstream release as its plain major, so the + # version alone ties; break it explicitly instead of inheriting whatever order + # versions.json already happened to have (sorted ascending here, so the plain major needs + # the higher value to land first once this is reversed) + (if .key | test("-(rc|next)$") then 0 else 1 end) + ]) | reverse | from_entries