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
0 commit comments