Skip to content

Commit 4bb5600

Browse files
authored
improvement(ci): decouple image builds from the test gate (#5701)
* improvement(ci): decouple image builds from the test gate - build-amd64 now starts immediately and pushes only sha tags (ECR :sha, GHCR :sha-amd64). The EventBridge deploy triggers filter on exactly the latest/staging/dev ECR tags, so nothing deploys from these pushes. - New promote-images job retags sha -> latest/staging (and GHCR latest-amd64/version-amd64) via buildx imagetools once tests and migrations pass — a seconds-long manifest copy instead of rebuilding after the gate. Cuts push-to-deploy from ~13.5 to ~7 minutes. - Split the Next.js production build out of test-build into a parallel Build App job with its own sticky-disk keys, cutting PR feedback from ~5.5 to ~3.5 minutes. - create-ghcr-manifests and process-docs now gate on promote-images. * improvement(ci): harden promotion — atomic retag, stale-run guards, gate all mutable GHCR tags Review follow-ups: - promote-images is a single job (not a matrix): verifies all four :sha manifests exist before moving any deploy tag, so a missing image can't cause a partial mixed-version deploy - stale-run guard on promote-images and create-ghcr-manifests: re-running an old run no longer retags latest/staging back to stale code (a one-click prod rollback); superseded runs skip mutable tags with a warning while immutable sha/version tags still publish - ARM64 build now pushes only the immutable :sha-arm64 tag; latest-arm64 and version tags moved behind the gate into create-ghcr-manifests (closes the pre-existing hole where a failing run moved latest-arm64) - dropped dead detect-version needs from both build jobs - sticky-disk comment corrected (clone + last-writer-wins, not exclusive mounts); build job shares warm bun/node_modules disks, keeps its own turbo cache key so test/build entries don't evict each other
1 parent 58e4b75 commit 4bb5600

2 files changed

Lines changed: 192 additions & 80 deletions

File tree

.github/workflows/ci.yml

Lines changed: 129 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ jobs:
4646
echo "ℹ️ Not a release commit"
4747
fi
4848
49-
# Run database migrations before images are pushed: the ECR push triggers
50-
# CodePipeline, so migrating first guarantees the schema is in place before
51-
# the new app version deploys (replaces the removed ECS migration sidecar)
49+
# Run database migrations before images are promoted: the ECR latest/staging
50+
# tag push triggers CodePipeline, so migrating first guarantees the schema is
51+
# in place before the new app version deploys (replaces the removed ECS
52+
# migration sidecar)
5253
migrate:
5354
name: Migrate DB
5455
needs: [test-build]
@@ -173,10 +174,13 @@ jobs:
173174
fi
174175
bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim
175176
176-
# Main/staging: build AMD64 images and push to ECR + GHCR
177+
# Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR.
178+
# Runs in parallel with tests — only immutable sha tags are pushed here, and
179+
# the CodePipeline EventBridge triggers filter on exactly the
180+
# latest/staging/dev ECR tags, so nothing deploys and no mutable tag moves
181+
# until promote-images / create-ghcr-manifests retag after the gate.
177182
build-amd64:
178183
name: Build AMD64
179-
needs: [test-build, detect-version, migrate]
180184
if: >-
181185
github.event_name == 'push' &&
182186
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
@@ -238,33 +242,20 @@ jobs:
238242
env:
239243
ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || matrix.ecr_repo_secret == 'ECR_PII' && secrets.ECR_PII || '' }}
240244

245+
# Only sha tags here — the ECR deploy tags (latest/staging) are applied
246+
# by promote-images and the GHCR latest-amd64/version tags by
247+
# create-ghcr-manifests, both after tests and migrations pass.
241248
- name: Generate tags
242249
id: meta
243250
run: |
244251
ECR_REGISTRY="${{ steps.login-ecr.outputs.registry }}"
245252
ECR_REPO="${{ steps.ecr-repo.outputs.name }}"
246253
GHCR_IMAGE="${{ matrix.ghcr_image }}"
247254
248-
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
249-
ECR_TAG="latest"
250-
else
251-
ECR_TAG="staging"
252-
fi
253-
ECR_IMAGE="${ECR_REGISTRY}/${ECR_REPO}:${ECR_TAG}"
254-
255-
TAGS="${ECR_IMAGE}"
255+
TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}"
256256
257257
if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then
258-
GHCR_AMD64="${GHCR_IMAGE}:latest-amd64"
259-
GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64"
260-
TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA"
261-
262-
if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then
263-
VERSION="${{ needs.detect-version.outputs.version }}"
264-
GHCR_VERSION="${GHCR_IMAGE}:${VERSION}-amd64"
265-
TAGS="${TAGS},$GHCR_VERSION"
266-
echo "📦 Adding version tag: ${VERSION}-amd64"
267-
fi
258+
TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64"
268259
fi
269260
270261
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
@@ -280,10 +271,85 @@ jobs:
280271
provenance: false
281272
sbom: false
282273

283-
# Build ARM64 images for GHCR (main branch only, runs in parallel)
274+
# Promote the sha-tagged ECR images to the deploy tags once tests and
275+
# migrations pass. Pushing the ECR latest/staging tag is what triggers
276+
# CodePipeline, so this seconds-long manifest retag is the deploy gate —
277+
# the image builds themselves run in parallel with the tests. A single job
278+
# (not a matrix) so all four sha manifests are verified before any tag
279+
# moves; a missing image can't produce a partial mixed-version deploy.
280+
promote-images:
281+
name: Promote Images
282+
needs: [migrate, build-amd64]
283+
if: >-
284+
github.event_name == 'push' &&
285+
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
286+
runs-on: blacksmith-2vcpu-ubuntu-2404
287+
permissions:
288+
contents: read
289+
id-token: write
290+
steps:
291+
- name: Configure AWS credentials
292+
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
293+
with:
294+
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
295+
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
296+
297+
- name: Login to Amazon ECR
298+
id: login-ecr
299+
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2
300+
301+
# Re-running this job from an old run would retag the deploy tags back
302+
# to that run's commit and immediately trigger a production deploy of
303+
# stale code. Only promote while this commit is still the branch head;
304+
# when superseded, skip cleanly — the newer run's promotion covers it.
305+
- name: Guard against stale promotion
306+
id: guard
307+
run: |
308+
HEAD_SHA="$(git ls-remote "https://github.com/${{ github.repository }}.git" "refs/heads/${GITHUB_REF_NAME}" | cut -f1)"
309+
if [ "$HEAD_SHA" != "${{ github.sha }}" ]; then
310+
echo "::warning::Skipping promotion of ${{ github.sha }}: ${GITHUB_REF_NAME} has moved to ${HEAD_SHA}. Moving the deploy tags to this commit would deploy stale code; push a revert commit to roll back instead."
311+
echo "fresh=false" >> $GITHUB_OUTPUT
312+
else
313+
echo "fresh=true" >> $GITHUB_OUTPUT
314+
fi
315+
316+
- name: Promote images to deploy tags
317+
if: steps.guard.outputs.fresh == 'true'
318+
env:
319+
ECR_REPOS: >-
320+
${{ secrets.ECR_APP }}
321+
${{ secrets.ECR_MIGRATIONS }}
322+
${{ secrets.ECR_REALTIME }}
323+
${{ secrets.ECR_PII }}
324+
run: |
325+
REGISTRY="${{ steps.login-ecr.outputs.registry }}"
326+
327+
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
328+
ECR_TAG="latest"
329+
else
330+
ECR_TAG="staging"
331+
fi
332+
333+
# Verify every sha image exists before moving any deploy tag, so a
334+
# missing/expired image aborts the whole promotion up front.
335+
for repo in $ECR_REPOS; do
336+
echo "🔍 Verifying ${repo}:${{ github.sha }}"
337+
docker buildx imagetools inspect "${REGISTRY}/${repo}:${{ github.sha }}" > /dev/null
338+
done
339+
340+
for repo in $ECR_REPOS; do
341+
echo "🚀 Promoting ${repo}:${{ github.sha }} to ${ECR_TAG}"
342+
docker buildx imagetools create \
343+
-t "${REGISTRY}/${repo}:${ECR_TAG}" \
344+
"${REGISTRY}/${repo}:${{ github.sha }}"
345+
done
346+
347+
# Build ARM64 images for GHCR (main branch only, runs in parallel with
348+
# tests). Pushes only the immutable sha tag — latest-arm64/version-arm64
349+
# are applied by create-ghcr-manifests after the gate, so a failing run
350+
# never moves a documented tag.
284351
build-ghcr-arm64:
285352
name: Build ARM64 (GHCR Only)
286-
needs: [detect-version]
287353
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
288354
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
289355
permissions:
@@ -316,37 +382,24 @@ jobs:
316382
- name: Set up Docker Buildx
317383
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
318384

319-
- name: Generate ARM64 tags
320-
id: meta
321-
run: |
322-
IMAGE="${{ matrix.image }}"
323-
TAGS="${IMAGE}:latest-arm64,${IMAGE}:${{ github.sha }}-arm64"
324-
325-
# Add version tag if this is a release commit
326-
if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then
327-
VERSION="${{ needs.detect-version.outputs.version }}"
328-
TAGS="${TAGS},${IMAGE}:${VERSION}-arm64"
329-
echo "📦 Adding version tag: ${VERSION}-arm64"
330-
fi
331-
332-
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
333-
334385
- name: Build and push ARM64 to GHCR
335386
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
336387
with:
337388
context: .
338389
file: ${{ matrix.dockerfile }}
339390
platforms: linux/arm64
340391
push: true
341-
tags: ${{ steps.meta.outputs.tags }}
392+
tags: ${{ matrix.image }}:${{ github.sha }}-arm64
342393
provenance: false
343394
sbom: false
344395

345-
# Create GHCR multi-arch manifests (only for main, after both builds)
396+
# Publish all mutable GHCR tags (latest, latest-amd64/arm64, version tags)
397+
# and the multi-arch manifests from the immutable sha tags — only on main,
398+
# after the deploy gate (promote-images) and the ARM64 build both pass.
346399
create-ghcr-manifests:
347400
name: Create GHCR Manifests
348401
runs-on: blacksmith-2vcpu-ubuntu-2404
349-
needs: [build-amd64, build-ghcr-arm64, detect-version]
402+
needs: [promote-images, build-ghcr-arm64, detect-version]
350403
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
351404
permissions:
352405
packages: write
@@ -366,30 +419,43 @@ jobs:
366419
username: ${{ github.repository_owner }}
367420
password: ${{ secrets.GITHUB_TOKEN }}
368421

369-
- name: Create and push manifests
422+
# Same protection as promote-images: a re-run of an old run must not
423+
# move the public latest tags back to a stale commit. Immutable tags
424+
# (sha and version) are still published — only latest moves are gated.
425+
- name: Guard against stale latest tags
426+
id: guard
370427
run: |
371-
IMAGE_BASE="${{ matrix.image }}"
428+
HEAD_SHA="$(git ls-remote "https://github.com/${{ github.repository }}.git" "refs/heads/${GITHUB_REF_NAME}" | cut -f1)"
429+
if [ "$HEAD_SHA" != "${{ github.sha }}" ]; then
430+
echo "::warning::${GITHUB_REF_NAME} has moved to ${HEAD_SHA}; publishing immutable tags for ${{ github.sha }} but skipping the latest tags."
431+
echo "fresh=false" >> $GITHUB_OUTPUT
432+
else
433+
echo "fresh=true" >> $GITHUB_OUTPUT
434+
fi
372435
373-
# Create latest manifest
374-
docker manifest create "${IMAGE_BASE}:latest" \
375-
"${IMAGE_BASE}:latest-amd64" \
376-
"${IMAGE_BASE}:latest-arm64"
377-
docker manifest push "${IMAGE_BASE}:latest"
436+
- name: Publish tags and manifests
437+
run: |
438+
IMAGE="${{ matrix.image }}"
439+
SHA="${{ github.sha }}"
378440
379-
# Create SHA manifest
380-
docker manifest create "${IMAGE_BASE}:${{ github.sha }}" \
381-
"${IMAGE_BASE}:${{ github.sha }}-amd64" \
382-
"${IMAGE_BASE}:${{ github.sha }}-arm64"
383-
docker manifest push "${IMAGE_BASE}:${{ github.sha }}"
441+
# Multi-arch manifest from the immutable per-arch sha tags
442+
docker buildx imagetools create -t "${IMAGE}:${SHA}" \
443+
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
384444
385-
# Create version manifest if this is a release commit
386445
if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then
387446
VERSION="${{ needs.detect-version.outputs.version }}"
388-
echo "📦 Creating version manifest: ${VERSION}"
389-
docker manifest create "${IMAGE_BASE}:${VERSION}" \
390-
"${IMAGE_BASE}:${VERSION}-amd64" \
391-
"${IMAGE_BASE}:${VERSION}-arm64"
392-
docker manifest push "${IMAGE_BASE}:${VERSION}"
447+
echo "📦 Publishing version tags: ${VERSION}"
448+
docker buildx imagetools create -t "${IMAGE}:${VERSION}-amd64" "${IMAGE}:${SHA}-amd64"
449+
docker buildx imagetools create -t "${IMAGE}:${VERSION}-arm64" "${IMAGE}:${SHA}-arm64"
450+
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
451+
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
452+
fi
453+
454+
if [ "${{ steps.guard.outputs.fresh }}" = "true" ]; then
455+
docker buildx imagetools create -t "${IMAGE}:latest-amd64" "${IMAGE}:${SHA}-amd64"
456+
docker buildx imagetools create -t "${IMAGE}:latest-arm64" "${IMAGE}:${SHA}-arm64"
457+
docker buildx imagetools create -t "${IMAGE}:latest" \
458+
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
393459
fi
394460
395461
# Check if docs changed
@@ -412,10 +478,10 @@ jobs:
412478
- 'apps/sim/scripts/process-docs.ts'
413479
- 'apps/sim/lib/chunkers/**'
414480
415-
# Process docs embeddings (only when docs change, after ECR images are pushed)
481+
# Process docs embeddings (only when docs change, after images are promoted)
416482
process-docs:
417483
name: Process Docs
418-
needs: [build-amd64, check-docs-changes]
484+
needs: [promote-images, check-docs-changes]
419485
if: needs.check-docs-changes.outputs.docs_changed == 'true'
420486
uses: ./.github/workflows/docs-embeddings.yml
421487
secrets: inherit

.github/workflows/test-build.yml

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ permissions:
99

1010
jobs:
1111
test-build:
12-
name: Test and Build
12+
name: Lint and Test
1313
runs-on: blacksmith-8vcpu-ubuntu-2404
1414

1515
steps:
@@ -44,14 +44,6 @@ jobs:
4444
key: ${{ github.repository }}-turbo-cache
4545
path: ./.turbo
4646

47-
- name: Restore Next.js build cache
48-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
49-
with:
50-
path: ./apps/sim/.next/cache
51-
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}
52-
restore-keys: |
53-
${{ runner.os }}-nextjs-
54-
5547
- name: Install dependencies
5648
run: bun install --frozen-lockfile
5749

@@ -175,6 +167,67 @@ jobs:
175167
fi
176168
echo "✅ Schema and migrations are in sync"
177169
170+
- name: Upload coverage to Codecov
171+
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5
172+
with:
173+
directory: ./apps/sim/coverage
174+
fail_ci_if_error: false
175+
verbose: true
176+
177+
# Next.js production build, in parallel with lint + tests. Sticky disks are
178+
# cloned from the last committed snapshot per job and committed last-writer-
179+
# wins, so concurrent mounts are safe. The bun/node_modules disks are shared
180+
# with test-build (identical content from the same lockfile — LWW loss is
181+
# harmless), but the Turbo cache gets its own key: with a shared key, only
182+
# the last committer's new entries survive each run, so the test and build
183+
# Turbo entries would evict each other nondeterministically.
184+
build:
185+
name: Build App
186+
runs-on: blacksmith-8vcpu-ubuntu-2404
187+
188+
steps:
189+
- name: Checkout code
190+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
191+
192+
- name: Setup Bun
193+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
194+
with:
195+
bun-version: 1.3.13
196+
197+
- name: Setup Node
198+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
199+
with:
200+
node-version: latest
201+
202+
- name: Mount Bun cache (Sticky Disk)
203+
uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
204+
with:
205+
key: ${{ github.repository }}-bun-cache
206+
path: ~/.bun/install/cache
207+
208+
- name: Mount node_modules (Sticky Disk)
209+
uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
210+
with:
211+
key: ${{ github.repository }}-node-modules
212+
path: ./node_modules
213+
214+
- name: Mount Turbo cache (Sticky Disk)
215+
uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
216+
with:
217+
key: ${{ github.repository }}-turbo-cache-build
218+
path: ./.turbo
219+
220+
- name: Restore Next.js build cache
221+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
222+
with:
223+
path: ./apps/sim/.next/cache
224+
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}
225+
restore-keys: |
226+
${{ runner.os }}-nextjs-
227+
228+
- name: Install dependencies
229+
run: bun install --frozen-lockfile
230+
178231
- name: Build application
179232
env:
180233
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
@@ -186,11 +239,4 @@ jobs:
186239
AWS_REGION: 'us-west-2'
187240
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
188241
TURBO_CACHE_DIR: .turbo
189-
run: bunx turbo run build --filter=sim
190-
191-
- name: Upload coverage to Codecov
192-
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5
193-
with:
194-
directory: ./apps/sim/coverage
195-
fail_ci_if_error: false
196-
verbose: true
242+
run: bunx turbo run build --filter=sim

0 commit comments

Comments
 (0)