Skip to content

Commit daf5ec7

Browse files
authored
Added foundation of a new Docker CI workflow (#24745)
This commit adds a new `.github/workflows/ci-docker.yml` workflow, including an initial job to build the Ghost development docker image and push it to Github's container registry with appropriate tags. It also includes a reusable action to pull the image in downstream jobs for running tests. It also includes a few changes to the Dockerfile to speed up the builds: - Removes playwright and dependencies. This means the `e2e-browser` test suite will no longer be able to be run in Docker (without extra steps). The trade-off is that it removes ~2.5 GB from the final image size, which reduces the time to push/pull the image, and currently very few if any people are using the Docker image for this purpose. - Adds a cache mount to the yarn install step, which also reduces the final image size significantly, even in the case of a cache miss at this step.
1 parent 37d858c commit daf5ec7

File tree

3 files changed

+264
-29
lines changed

3 files changed

+264
-29
lines changed

.docker/Dockerfile

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,40 +12,19 @@ RUN apt-get update && \
1212
libjemalloc2 \
1313
python3 \
1414
tar \
15-
git && \
16-
rm -rf /var/lib/apt/lists/* && \
17-
apt clean
18-
19-
# --------------------
20-
# Playwright Version
21-
# --------------------
22-
# Cache Optimization: Extract the playwright version from package.json
23-
FROM base AS playwright-version
24-
WORKDIR /tmp
25-
COPY ghost/core/package.json ./
26-
RUN jq -r '.devDependencies."@playwright/test"' ./package.json > playwright-version.txt
27-
28-
# --------------------
29-
# Playwright
30-
# --------------------
31-
# Cache Optimization: Playwright install is slow. Copy the version from the previous stage.
32-
# This way we only bust build cache when the playwright version changes.
33-
FROM base AS playwright
34-
RUN curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | tee /usr/share/keyrings/stripe.gpg && \
15+
git && \
16+
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | tee /usr/share/keyrings/stripe.gpg && \
3517
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | tee -a /etc/apt/sources.list.d/stripe.list && \
36-
apt update && \
37-
apt install -y \
18+
apt-get update && \
19+
apt-get install -y \
3820
stripe && \
3921
rm -rf /var/lib/apt/lists/* && \
4022
apt clean
41-
WORKDIR /home/ghost
42-
COPY --from=playwright-version tmp/playwright-version.txt /tmp/playwright-version.txt
43-
RUN npx playwright@$(cat /tmp/playwright-version.txt) install --with-deps
4423

4524
# --------------------
4625
# Development Base
4726
# --------------------
48-
FROM playwright AS development-base
27+
FROM base AS development-base
4928
WORKDIR /home/ghost
5029

5130
COPY package.json yarn.lock ./
@@ -78,7 +57,8 @@ COPY ghost/i18n/package.json ghost/i18n/package.json
7857
# Copy patches directory so patch-package can apply patches during yarn install
7958
COPY patches patches
8059

81-
RUN yarn install --frozen-lockfile --prefer-offline
60+
RUN --mount=type=cache,target=/usr/local/share/.cache/yarn,id=yarn-cache \
61+
yarn install --frozen-lockfile --prefer-offline
8262

8363
# --------------------
8464
# Shade Builder
@@ -152,6 +132,7 @@ RUN cd apps/admin-x-settings && yarn build
152132
FROM development-base AS admin-x-activitypub-builder
153133
WORKDIR /home/ghost
154134
COPY apps/admin-x-activitypub apps/admin-x-activitypub
135+
COPY ghost/core/core/frontend/src/cards ghost/core/core/frontend/src/cards
155136
COPY --from=shade-builder /home/ghost/apps/shade apps/shade
156137
COPY --from=admin-x-design-system-builder /home/ghost/apps/admin-x-design-system/es apps/admin-x-design-system/es
157138
COPY --from=admin-x-design-system-builder /home/ghost/apps/admin-x-design-system/types apps/admin-x-design-system/types
@@ -165,12 +146,14 @@ RUN cd apps/admin-x-activitypub && yarn build
165146
FROM development-base AS admin-ember-builder
166147
WORKDIR /home/ghost
167148
COPY ghost/admin ghost/admin
168-
COPY ghost/core ghost/core
149+
# Admin's asset-delivery pipeline needs the ghost module to resolve
150+
COPY ghost/core/package.json ghost/core/package.json
151+
COPY ghost/core/index.js ghost/core/index.js
169152
COPY --from=stats-builder /home/ghost/apps/stats/dist apps/stats/dist
170153
COPY --from=posts-builder /home/ghost/apps/posts/dist apps/posts/dist
171154
COPY --from=admin-x-settings-builder /home/ghost/apps/admin-x-settings/dist apps/admin-x-settings/dist
172155
COPY --from=admin-x-activitypub-builder /home/ghost/apps/admin-x-activitypub/dist apps/admin-x-activitypub/dist
173-
RUN cd ghost/admin && yarn build
156+
RUN mkdir -p ghost/core/core/built/admin && cd ghost/admin && yarn build
174157

175158
# --------------------
176159
# Development
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: 'Load Docker Image'
2+
description: 'Load Docker image from registry or artifact based on build source'
3+
inputs:
4+
is-fork:
5+
description: 'Whether this is a fork PR build'
6+
required: true
7+
image-tags:
8+
description: 'Docker image tags (multi-line string)'
9+
required: true
10+
11+
runs:
12+
using: 'composite'
13+
steps:
14+
- name: Download image artifact (fork PR)
15+
if: inputs.is-fork == 'true'
16+
uses: actions/download-artifact@v4
17+
with:
18+
name: docker-image
19+
20+
- name: Load image from artifact (fork PR)
21+
if: inputs.is-fork == 'true'
22+
shell: bash
23+
run: |
24+
echo "Loading Docker image from artifact..."
25+
gunzip -c docker-image.tar.gz | docker load
26+
echo "Available images after load:"
27+
docker images
28+
29+
- name: Log in to GitHub Container Registry
30+
if: inputs.is-fork == 'false'
31+
uses: docker/login-action@v3
32+
with:
33+
registry: ghcr.io
34+
username: ${{ github.actor }}
35+
password: ${{ github.token }}
36+
37+
- name: Pull image from registry (main repo/branch)
38+
if: inputs.is-fork == 'false'
39+
shell: bash
40+
run: |
41+
IMAGE_TAG=$(echo "${{ inputs.image-tags }}" | head -n1)
42+
echo "Pulling image from registry: $IMAGE_TAG"
43+
docker pull "$IMAGE_TAG"
44+
45+
- name: Verify image is available
46+
id: verify
47+
shell: bash
48+
run: |
49+
IMAGE_TAG=$(echo "${{ inputs.image-tags }}" | head -n1)
50+
echo "Checking for image: $IMAGE_TAG"
51+
echo "image-tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
52+
53+
if docker inspect "$IMAGE_TAG" > /dev/null 2>&1; then
54+
echo "✅ Image $IMAGE_TAG is available"
55+
else
56+
echo "❌ Image $IMAGE_TAG is not available"
57+
echo "Available images:"
58+
docker images
59+
exit 1
60+
fi
61+
62+
63+
outputs:
64+
image-tag:
65+
description: 'The Docker image tag to use'
66+
value: ${{ steps.verify.outputs.image-tag }}

.github/workflows/ci-docker.yml

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
name: CI (Docker)
2+
on:
3+
workflow_dispatch:
4+
pull_request:
5+
types: [opened, synchronize, reopened, labeled, unlabeled]
6+
push:
7+
# Ref: GHA Filter pattern syntax: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#filter-pattern-cheat-sheet
8+
# Run on pushes to main, release branches, and previous/future major version branches
9+
branches:
10+
- main
11+
- 'v[0-9]+.*' # Matches any release branch, e.g. v6.0.3, v12.1.0
12+
- '[0-9]+.x' # Matches any major version branch, e.g. 5.x, 23.x
13+
14+
jobs:
15+
build:
16+
name: Build & Push
17+
runs-on: ubuntu-latest-16-cores
18+
permissions:
19+
contents: read
20+
packages: write
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v4
24+
with:
25+
submodules: true
26+
27+
- name: Set up Docker Buildx
28+
uses: docker/setup-buildx-action@v3
29+
30+
- name: Log in to GitHub Container Registry
31+
uses: docker/login-action@v3
32+
with:
33+
registry: ghcr.io
34+
username: ${{ github.actor }}
35+
password: ${{ secrets.GITHUB_TOKEN }}
36+
37+
# We can't access secrets from forks, so we can't push to the registry
38+
# Instead, we upload the image as an artifact, and download it in downstream jobs
39+
- name: Determine build strategy
40+
id: strategy
41+
run: |
42+
IS_FORK_PR="false"
43+
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
44+
IS_FORK_PR="true"
45+
fi
46+
IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/ghost-development"
47+
if [ "$IS_FORK_PR" = "true" ]; then
48+
IMAGE_NAME="ghcr.io/${{ github.event.pull_request.head.repo.owner.login }}/ghost-development"
49+
fi
50+
CACHE_KEY=$(echo $IMAGE_NAME | tr '[:upper:]' '[:lower:]')
51+
echo "is-fork-pr=$IS_FORK_PR" >> $GITHUB_OUTPUT
52+
echo "should-push=$( [ "$IS_FORK_PR" = "false" ] && echo "true" || echo "false" )" >> $GITHUB_OUTPUT
53+
echo "should-load=$( [ "$IS_FORK_PR" = "true" ] && echo "true" || echo "false" )" >> $GITHUB_OUTPUT
54+
echo "image-name=$IMAGE_NAME" >> $GITHUB_OUTPUT
55+
echo "cache-key=$CACHE_KEY" >> $GITHUB_OUTPUT
56+
echo "Build Strategy: "
57+
echo " Is fork PR: $IS_FORK_PR"
58+
echo " Should push: $( [ "$IS_FORK_PR" = "false" ] && echo "true" || echo "false" )"
59+
echo " Should load: $( [ "$IS_FORK_PR" = "true" ] && echo "true" || echo "false" )"
60+
echo " Image name: $IMAGE_NAME"
61+
echo " Cache key: $CACHE_KEY"
62+
63+
- name: Docker meta
64+
id: meta
65+
uses: docker/metadata-action@v5
66+
with:
67+
images: |
68+
${{ steps.strategy.outputs.image-name }}
69+
tags: |
70+
type=ref,event=branch
71+
type=ref,event=pr
72+
type=sha
73+
type=raw,value=latest,enable={{is_default_branch}}
74+
labels: |
75+
org.opencontainers.image.title=Ghost Development
76+
org.opencontainers.image.description=Ghost development build
77+
org.opencontainers.image.vendor=TryGhost
78+
maintainer=TryGhost
79+
80+
- name: Build and push Docker image
81+
uses: docker/build-push-action@v6
82+
id: build
83+
with:
84+
context: .
85+
file: .docker/Dockerfile
86+
push: ${{ steps.strategy.outputs.should-push }}
87+
load: ${{ steps.strategy.outputs.should-load }}
88+
tags: ${{ steps.meta.outputs.tags }}
89+
labels: ${{ steps.meta.outputs.labels }}
90+
# On PRs: use both main cache and PR-specific cache
91+
# On main: only use main cache
92+
cache-from: |
93+
type=registry,ref=${{ steps.strategy.outputs.cache-key }}:cache-main
94+
${{ github.event_name == 'pull_request' && format('type=registry,ref={0}:cache-pr-{1}', steps.strategy.outputs.cache-key, github.event.pull_request.number) || '' }}
95+
# Only export cache if we can push (not on fork PRs)
96+
cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.cache-key, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }}
97+
98+
- name: Save image as artifact (fork PR)
99+
if: steps.strategy.outputs.is-fork-pr == 'true'
100+
run: |
101+
# Get the first tag from the multi-line tags output
102+
IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n1)
103+
echo "Saving image: $IMAGE_TAG"
104+
docker save "$IMAGE_TAG" | gzip > docker-image.tar.gz
105+
echo "Image saved as docker-image.tar.gz"
106+
ls -lh docker-image.tar.gz
107+
108+
- name: Upload image artifact (fork PR)
109+
if: steps.strategy.outputs.is-fork-pr == 'true'
110+
uses: actions/upload-artifact@v4
111+
with:
112+
name: docker-image
113+
path: docker-image.tar.gz
114+
retention-days: 1
115+
116+
outputs:
117+
image-tags: ${{ steps.meta.outputs.tags }}
118+
image-digest: ${{ steps.build.outputs.digest }}
119+
is-fork: ${{ steps.strategy.outputs.is-fork-pr }}
120+
121+
inspect_image:
122+
name: Inspect Docker Image
123+
needs: build
124+
runs-on: ubuntu-latest
125+
126+
steps:
127+
- name: Checkout repository
128+
uses: actions/checkout@v4
129+
with:
130+
sparse-checkout: |
131+
.github/actions/load-docker-image
132+
133+
- name: Load Docker Image
134+
id: load
135+
uses: ./.github/actions/load-docker-image
136+
with:
137+
is-fork: ${{ needs.build.outputs.is-fork }}
138+
image-tags: ${{ needs.build.outputs.image-tags }}
139+
140+
- name: Inspect image size and layers
141+
shell: bash
142+
run: |
143+
IMAGE_TAG="${{ steps.load.outputs.image-tag }}"
144+
echo "Analyzing Docker image: $IMAGE_TAG"
145+
146+
# Get the image size in bytes
147+
IMAGE_SIZE_BYTES=$(docker inspect "$IMAGE_TAG" --format='{{.Size}}')
148+
149+
# Convert to human readable format
150+
IMAGE_SIZE_MB=$(( IMAGE_SIZE_BYTES / 1024 / 1024 ))
151+
IMAGE_SIZE_GB=$(echo "scale=2; $IMAGE_SIZE_BYTES / 1024 / 1024 / 1024" | bc)
152+
153+
# Format size display based on magnitude
154+
if [ $IMAGE_SIZE_MB -ge 1024 ]; then
155+
IMAGE_SIZE_DISPLAY="${IMAGE_SIZE_GB} GB"
156+
else
157+
IMAGE_SIZE_DISPLAY="${IMAGE_SIZE_MB} MB"
158+
fi
159+
160+
echo "Image size: ${IMAGE_SIZE_DISPLAY}"
161+
162+
# Write to GitHub Step Summary
163+
{
164+
echo "# Docker Image Analysis"
165+
echo ""
166+
echo "**Image:** \`$IMAGE_TAG\`"
167+
echo ""
168+
echo "**Total Size:** ${IMAGE_SIZE_DISPLAY}"
169+
echo ""
170+
echo "## Image Layers"
171+
echo ""
172+
echo "| Size | Layer |"
173+
echo "|------|-------|"
174+
175+
# Get all layers (including 0B ones)
176+
docker history "$IMAGE_TAG" --format "{{.Size}}@@@{{.CreatedBy}}" --no-trunc | \
177+
while IFS='@@@' read -r size cmd; do
178+
# Clean up the command for display
179+
cmd_clean=$(echo "$cmd" | sed 's/^\/bin\/sh -c //' | sed 's/^#(nop) //' | sed 's/^@@//' | sed 's/|/\\|/g' | cut -c1-80)
180+
if [ ${#cmd} -gt 80 ]; then
181+
cmd_clean="${cmd_clean}..."
182+
fi
183+
echo "| $size | \`${cmd_clean}\` |"
184+
done
185+
186+
} >> $GITHUB_STEP_SUMMARY

0 commit comments

Comments
 (0)