Skip to content

Commit c5c82df

Browse files
EliShteinmanclaude
andcommitted
perf(airgap): per-version cache, gzip cleanup, staged workflow
Three coupled changes to reduce build time and surface progress: 1. airgap-multibuild.sh: per-version output cache keyed by content hash. For each (product, version) build, hashes the version's content + shared layouts + config + script. On hit, skips Hugo entirely and restores the cached output. On miss, builds and saves to cache. Backed by a BuildKit cache mount on /var/cache/airgap-versions, so the cache persists across builds (and across workflow runs via cache-to=type=gha,mode=max). Expected savings: ~30 min on a typical merge that only changes one version's content. 2. Dockerfile: split builder into three named stages (deps, components, builder) so the workflow can target each independently and the run summary shows progress between them. Removes .json from the gzip pre-compression list — nginx serves .json with sub_filter for the __DOCS_BASE_URL__ placeholder, which requires gzip_static OFF, so pre-compressing those ~12k files is wasted CPU. 3. Workflow: replace the single docker/build-push-action call with five sequential steps (deps → components → builder → push priv → push unpriv). After each step, append a checkmark line to the run summary so you see progress between stages in real time. The build plan now includes a progress table seeded with ⏳ pending entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5afd5d3 commit c5c82df

3 files changed

Lines changed: 199 additions & 37 deletions

File tree

.github/workflows/airgap-build.yml

Lines changed: 107 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@ jobs:
5555
uses: actions/checkout@v4
5656

5757
# ----------------------------------------------------------------------
58-
# Step 1: Compute build parameters. Inputs are passed via env: to avoid
59-
# any shell-injection risk from the free-text tag_override field.
58+
# Setup
6059
# ----------------------------------------------------------------------
6160
- name: Compute build params
6261
id: meta
@@ -120,29 +119,37 @@ jobs:
120119
echo "|---------|-------|"
121120
echo "| Commit | \`$HASH\` |"
122121
echo "| Branch | \`$BRANCH\` |"
123-
echo "| Variant input | \`$INPUT_VARIANT\` |"
122+
echo "| Variant | \`$INPUT_VARIANT\` |"
124123
echo "| Platforms | \`$PLATFORMS\` |"
125124
echo "| Tag override | \`$OVERRIDE_DISPLAY\` |"
126125
echo ""
127126
128127
if [ "$INPUT_VARIANT" != "unprivileged" ]; then
129-
echo "### Privileged tags about to be pushed"
128+
echo "### Privileged tags to push"
130129
echo '```'
131130
echo "$PRIV_TAGS"
132131
echo '```'
133132
fi
134133
135134
if [ "$INPUT_VARIANT" != "privileged" ]; then
136-
echo "### Unprivileged tags about to be pushed"
135+
echo "### Unprivileged tags to push"
137136
echo '```'
138137
echo "$UNPRIV_TAGS"
139138
echo '```'
140139
fi
140+
141+
echo ""
142+
echo "## Progress"
143+
echo ""
144+
echo "| Stage | Status |"
145+
echo "|-------|--------|"
146+
echo "| 1/5: Install dependencies (apt + hugo + npm + pip) | ⏳ pending |"
147+
echo "| 2/5: Clone external components (make components) | ⏳ pending |"
148+
echo "| 3/5: Hugo multi-version build + gzip | ⏳ pending |"
149+
echo "| 4/5: Build & push privileged | ⏳ pending |"
150+
echo "| 5/5: Build & push unprivileged | ⏳ pending |"
141151
} >> "$GITHUB_STEP_SUMMARY"
142152
143-
# ----------------------------------------------------------------------
144-
# Step 2: Cross-arch setup (skipped when building amd64-only)
145-
# ----------------------------------------------------------------------
146153
- name: Set up QEMU (for arm64 runtime layer)
147154
if: inputs.platforms != 'amd64'
148155
uses: docker/setup-qemu-action@v4
@@ -159,12 +166,78 @@ jobs:
159166
password: ${{ secrets.DOCKERHUB_TOKEN }}
160167

161168
# ----------------------------------------------------------------------
162-
# Step 3: privileged. The Dockerfile pins builder to $BUILDPLATFORM
163-
# (amd64 native on this runner), so the heavy work runs ONCE.
164-
# `cache-to: type=gha,mode=max` writes intermediates to GitHub Actions
165-
# cache → next workflow run starts with a warm cache.
169+
# Stage 1/5: Install build dependencies
166170
# ----------------------------------------------------------------------
167-
- name: Build & push privileged
171+
- name: "Stage 1/5: Install build dependencies"
172+
id: stage1
173+
uses: docker/build-push-action@v7
174+
with:
175+
context: .
176+
target: deps
177+
cache-from: type=gha
178+
cache-to: type=gha,mode=max
179+
push: false
180+
- name: "Stage 1/5: summary"
181+
if: always() && steps.stage1.conclusion != 'skipped'
182+
env:
183+
STAGE_OUTCOME: ${{ steps.stage1.outcome }}
184+
run: |
185+
ICON="✅"
186+
[ "$STAGE_OUTCOME" != "success" ] && ICON="❌"
187+
echo "- $ICON **Stage 1/5**: Dependencies installed ($STAGE_OUTCOME)" >> "$GITHUB_STEP_SUMMARY"
188+
189+
# ----------------------------------------------------------------------
190+
# Stage 2/5: Clone external example repos
191+
# ----------------------------------------------------------------------
192+
- name: "Stage 2/5: Clone external components"
193+
id: stage2
194+
uses: docker/build-push-action@v7
195+
with:
196+
context: .
197+
target: components
198+
secrets: |
199+
PRIVATE_ACCESS_TOKEN=${{ secrets.PRIVATE_ACCESS_TOKEN }}
200+
cache-from: type=gha
201+
cache-to: type=gha,mode=max
202+
push: false
203+
- name: "Stage 2/5: summary"
204+
if: always() && steps.stage2.conclusion != 'skipped'
205+
env:
206+
STAGE_OUTCOME: ${{ steps.stage2.outcome }}
207+
run: |
208+
ICON="✅"
209+
[ "$STAGE_OUTCOME" != "success" ] && ICON="❌"
210+
echo "- $ICON **Stage 2/5**: External components cloned ($STAGE_OUTCOME)" >> "$GITHUB_STEP_SUMMARY"
211+
212+
# ----------------------------------------------------------------------
213+
# Stage 3/5: Hugo multi-version build (the long one, ~30 min on GHA
214+
# for a fully-cold build, much less when the per-version cache mount
215+
# has entries from previous runs).
216+
# ----------------------------------------------------------------------
217+
- name: "Stage 3/5: Hugo multi-version build + gzip"
218+
id: stage3
219+
uses: docker/build-push-action@v7
220+
with:
221+
context: .
222+
target: builder
223+
cache-from: type=gha
224+
cache-to: type=gha,mode=max
225+
push: false
226+
- name: "Stage 3/5: summary"
227+
if: always() && steps.stage3.conclusion != 'skipped'
228+
env:
229+
STAGE_OUTCOME: ${{ steps.stage3.outcome }}
230+
run: |
231+
ICON="✅"
232+
[ "$STAGE_OUTCOME" != "success" ] && ICON="❌"
233+
echo "- $ICON **Stage 3/5**: Hugo multi-build + gzip complete ($STAGE_OUTCOME)" >> "$GITHUB_STEP_SUMMARY"
234+
235+
# ----------------------------------------------------------------------
236+
# Stage 4/5: Build & push privileged. Builder layers come from cache;
237+
# only the runtime stage (COPY + LABEL) actually runs, then push.
238+
# ----------------------------------------------------------------------
239+
- name: "Stage 4/5: Build & push privileged"
240+
id: stage4
168241
if: inputs.variant != 'unprivileged'
169242
uses: docker/build-push-action@v7
170243
with:
@@ -179,12 +252,20 @@ jobs:
179252
cache-to: type=gha,mode=max
180253
tags: ${{ steps.meta.outputs.priv_tags }}
181254
push: true
255+
- name: "Stage 4/5: summary"
256+
if: always() && steps.stage4.conclusion != 'skipped'
257+
env:
258+
STAGE_OUTCOME: ${{ steps.stage4.outcome }}
259+
run: |
260+
ICON="✅"
261+
[ "$STAGE_OUTCOME" != "success" ] && ICON="❌"
262+
echo "- $ICON **Stage 4/5**: Privileged pushed to DockerHub ($STAGE_OUTCOME)" >> "$GITHUB_STEP_SUMMARY"
182263
183264
# ----------------------------------------------------------------------
184-
# Step 4: unprivileged. The builder stage is CACHED from step 3 (same
185-
# runner, in-memory BuildKit). Only the runtime stage actually runs.
265+
# Stage 5/5: Build & push unprivileged
186266
# ----------------------------------------------------------------------
187-
- name: Build & push unprivileged
267+
- name: "Stage 5/5: Build & push unprivileged"
268+
id: stage5
188269
if: inputs.variant != 'privileged'
189270
uses: docker/build-push-action@v7
190271
with:
@@ -199,10 +280,18 @@ jobs:
199280
cache-to: type=gha,mode=max
200281
tags: ${{ steps.meta.outputs.unpriv_tags }}
201282
push: true
283+
- name: "Stage 5/5: summary"
284+
if: always() && steps.stage5.conclusion != 'skipped'
285+
env:
286+
STAGE_OUTCOME: ${{ steps.stage5.outcome }}
287+
run: |
288+
ICON="✅"
289+
[ "$STAGE_OUTCOME" != "success" ] && ICON="❌"
290+
echo "- $ICON **Stage 5/5**: Unprivileged pushed to DockerHub ($STAGE_OUTCOME)" >> "$GITHUB_STEP_SUMMARY"
202291
203292
# ----------------------------------------------------------------------
204-
# Step 5: final summary with pull commands (runs even if build failed,
205-
# so you see what was attempted)
293+
# Final summary with pull/cleanup commands. Runs even on failure so
294+
# you see what was attempted.
206295
# ----------------------------------------------------------------------
207296
- name: Write final summary with pull / cleanup commands
208297
if: always()

Dockerfile

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,16 @@
33
ARG VARIANT=privileged
44

55
# ============================================================
6-
# Builder stage (shared by both variants AND both target arches)
6+
# Stage: deps (apt + hugo + npm + pip)
77
# ============================================================
8-
# Force the builder to run on the host's native platform — the output is
9-
# static HTML/CSS/JS that's identical regardless of target arch, so there's
10-
# no point running Hugo + npm + pip twice (once natively and once under
11-
# QEMU emulation) for a multi-arch build. Each target's runtime stage
12-
# COPYs the same /site/public out of this single builder.
13-
FROM --platform=$BUILDPLATFORM node:24-trixie AS builder
8+
# Force builder-side stages to run on the host's native platform — the output
9+
# is static HTML/CSS/JS that's identical regardless of target arch, so there's
10+
# no point running Hugo + npm + pip twice under QEMU emulation. Each target's
11+
# runtime stage COPYs the same /site/public out of the final builder stage.
12+
FROM --platform=$BUILDPLATFORM node:24-trixie AS deps
1413

1514
ARG HUGO_VERSION=0.143.1
1615
ARG BUILDARCH
17-
ARG GIT_COMMIT=unknown
18-
ARG BUILD_DATE=unknown
1916

2017
RUN apt-get update && apt-get install -y --no-install-recommends \
2118
python3 \
@@ -40,28 +37,45 @@ RUN npm install
4037
COPY requirements.txt ./
4138
RUN python3 -m venv /venv && /venv/bin/pip install -r requirements.txt
4239

40+
# ============================================================
41+
# Stage: components (COPY workspace + make components)
42+
# ============================================================
43+
FROM deps AS components
44+
4345
COPY . .
4446

4547
ENV PATH="/venv/bin:$PATH"
4648

4749
RUN sed -i 's#baseURL = "https://redis.io"#baseURL = "/"#g' config.toml
4850
# Hugo per-partial timeout: upstream sets 75s, which fits CI but not multi-platform
49-
# Docker builds where dynacache is constantly evicted under memory pressure and
50-
# QEMU-emulated amd64 plus parallel arm64 share a single host's resources.
51+
# Docker builds where dynacache is constantly evicted under memory pressure.
5152
RUN sed -i 's/timeout="75"/timeout="600"/' config.toml
5253

53-
# Fetch external client repos (clones into examples/). Cached unless deps or
54-
# build/make.py change. Cannot move into the multi-build below because each
55-
# version build resets the workspace; we want examples/ in the snapshot.
54+
# Fetch external client repos (clones into examples/). Cannot move into the
55+
# multi-build below because each version build resets the workspace; we want
56+
# examples/ in the snapshot.
5657
RUN --mount=type=secret,id=PRIVATE_ACCESS_TOKEN,env=PRIVATE_ACCESS_TOKEN \
5758
make components
5859

60+
# ============================================================
61+
# Stage: builder (multi-version Hugo build + gzip pre-compression)
62+
# ============================================================
63+
FROM components AS builder
64+
5965
# Multi-build pipeline: latest + one Hugo invocation per (product, version),
6066
# then merged into a single public/ tree. See airgap-multibuild.sh.
61-
# Mirrors .github/workflows/main.yml's parallel matrix as a sequential loop.
62-
RUN bash airgap-multibuild.sh
63-
64-
RUN find /site/public -type f \( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.json" -o -name "*.xml" -o -name "*.svg" -o -name "*.txt" \) \
67+
#
68+
# The cache mount preserves per-version Hugo outputs across builds. The script
69+
# computes a content-hash per version and reuses cached outputs when the hash
70+
# matches — so a merge that only touches one version's content rebuilds only
71+
# that version, not the other 27.
72+
RUN --mount=type=cache,target=/var/cache/airgap-versions \
73+
bash airgap-multibuild.sh
74+
75+
# Pre-compress static assets that nginx serves via gzip_static. Skips .md and
76+
# .json because nginx runs sub_filter on those at request time (gzip_static is
77+
# OFF for those locations — pre-compressing them would be wasted CPU).
78+
RUN find /site/public -type f \( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.xml" -o -name "*.svg" -o -name "*.txt" \) \
6579
-exec gzip -9 -k {} \;
6680

6781
# ============================================================

airgap-multibuild.sh

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@ set -euo pipefail
1717
readonly SITE=/site
1818
readonly SNAPSHOT=/tmp/site-snapshot
1919
readonly FINAL=/tmp/public-final
20+
# Per-version output cache. The Dockerfile mounts this path via
21+
# `RUN --mount=type=cache,target=/var/cache/airgap-versions`, so it persists
22+
# across builds (and across workflow runs when cache-to=type=gha,mode=max).
23+
readonly CACHE_ROOT=/var/cache/airgap-versions
24+
mkdir -p "$CACHE_ROOT"
25+
26+
# Hash everything that affects rendering for a given (product, version):
27+
# the version's own content, shared layouts, data, top-level configs, and
28+
# the script itself. Identical inputs → identical key → cache reuse.
29+
compute_version_cache_key() {
30+
local product_path=$1
31+
local version=$2
32+
{
33+
[ -d "$SNAPSHOT/content/$product_path/$version" ] && \
34+
find "$SNAPSHOT/content/$product_path/$version" -type f -print0 | sort -z | xargs -0 sha256sum
35+
find "$SNAPSHOT/layouts" -type f -print0 | sort -z | xargs -0 sha256sum
36+
[ -d "$SNAPSHOT/data" ] && \
37+
find "$SNAPSHOT/data" -type f -print0 | sort -z | xargs -0 sha256sum
38+
for f in config.toml postcss.config.js tailwind.config.js package-lock.json; do
39+
[ -f "$SNAPSHOT/$f" ] && sha256sum "$SNAPSHOT/$f"
40+
done
41+
sha256sum "$SITE/airgap-multibuild.sh"
42+
printf 'product=%s version=%s\n' "$product_path" "$version"
43+
} 2>/dev/null | sha256sum | cut -c1-16
44+
}
2045

2146
cd "$SITE"
2247

@@ -116,7 +141,26 @@ build_version() {
116141
local version=$4
117142
local all_versions=$5 # newline-separated version list for this product
118143

119-
echo ">>> Building ${product_path} v${version}"
144+
local cache_key
145+
cache_key=$(compute_version_cache_key "$product_path" "$version")
146+
local pp_slug="${product_path//\//-}"
147+
local cache_dir="$CACHE_ROOT/${pp_slug}-v${version}-${cache_key}"
148+
149+
if [ -d "$cache_dir/version" ]; then
150+
echo ">>> [CACHE HIT] ${product_path} v${version} (key=${cache_key})"
151+
mkdir -p "$FINAL/$product_path"
152+
rm -rf "$FINAL/$product_path/$version"
153+
cp -a "$cache_dir/version" "$FINAL/$product_path/$version"
154+
for d in css scss; do
155+
if [ -d "$cache_dir/$d" ]; then
156+
mkdir -p "$FINAL/$d"
157+
cp -an "$cache_dir/$d/." "$FINAL/$d/"
158+
fi
159+
done
160+
return
161+
fi
162+
163+
echo ">>> [CACHE MISS] Building ${product_path} v${version} (key=${cache_key})"
120164
reset_workspace
121165
cd "$SITE"
122166

@@ -190,6 +234,21 @@ build_version() {
190234
fi
191235
done
192236

237+
# Save the fresh build to the per-version cache so the next build with the
238+
# same inputs is a cache hit. We use a temp dir + atomic rename so a build
239+
# killed mid-write doesn't poison the cache.
240+
local cache_tmp="${cache_dir}.tmp.$$"
241+
rm -rf "$cache_tmp"
242+
mkdir -p "$cache_tmp"
243+
cp -a "$SITE/public/$product_path/$version" "$cache_tmp/version"
244+
for d in css scss; do
245+
if [ -d "$SITE/public/$d" ]; then
246+
cp -a "$SITE/public/$d" "$cache_tmp/$d"
247+
fi
248+
done
249+
rm -rf "$cache_dir"
250+
mv "$cache_tmp" "$cache_dir"
251+
193252
rm -rf "$SITE/public"
194253
}
195254

0 commit comments

Comments
 (0)