@@ -141,7 +141,7 @@ jobs:
141141 environment : dev
142142 secrets : inherit
143143
144- # Dev: build all 3 images for ECR only (no GHCR, no ARM64)
144+ # Dev publishes SHA artifacts first; only promote-images moves deploy aliases.
145145 build-dev :
146146 name : Build Dev ECR
147147 needs : [detect-version, migrate-dev]
@@ -215,13 +215,25 @@ jobs:
215215 env :
216216 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 || '' }}
217217
218+ - name : Reuse existing SHA artifact
219+ id : artifact
220+ env :
221+ ECR_REPO : ${{ steps.ecr-repo.outputs.name }}
222+ run : |
223+ [[ "$GITHUB_SHA" =~ ^[a-f0-9]{40}$ ]] && test -n "$ECR_REPO"
224+ result="$(aws ecr batch-get-image --repository-name "$ECR_REPO" --image-ids "imageTag=$GITHUB_SHA" --output json)"
225+ jq -e 'all(.failures[]; .failureCode == "ImageNotFound")' <<< "$result" > /dev/null
226+ echo "exists=$(jq -r '.images | length == 1' <<< "$result")" >> "$GITHUB_OUTPUT"
227+
218228 - name : Build and push
229+ if : steps.artifact.outputs.exists != 'true'
219230 uses : ./.github/actions/docker-build
220231 with :
221232 provider : ${{ vars.CI_PROVIDER }}
222233 file : ${{ matrix.dockerfile }}
223234 platforms : linux/amd64
224- tags : ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev
235+ tags : ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}
236+ build-args : COMMIT_SHA=${{ github.sha }}
225237 max-cache-size-mb : ${{ matrix.cache_mb }}
226238
227239 # Dev: deploy Trigger.dev background tasks to the preview "dev-sim" branch.
@@ -232,7 +244,9 @@ jobs:
232244 needs : [migrate-dev]
233245 if : github.event_name == 'push' && github.ref == 'refs/heads/dev'
234246 runs-on : ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
235- timeout-minutes : 15
247+ timeout-minutes : 45
248+ outputs :
249+ deployment_version : ${{ steps.deploy.outputs.deploymentVersion }}
236250 steps :
237251 - name : Checkout code
238252 uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -257,6 +271,7 @@ jobs:
257271 run : bun install --frozen-lockfile --ignore-scripts
258272
259273 - name : Deploy to Trigger.dev
274+ id : deploy
260275 working-directory : ./apps/sim
261276 env :
262277 TRIGGER_ACCESS_TOKEN : ${{ secrets.DEV_TRIGGER_ACCESS_TOKEN }}
@@ -266,7 +281,8 @@ jobs:
266281 echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2
267282 exit 1
268283 fi
269- bunx trigger.dev@4.5.12 deploy --env preview --branch dev-sim
284+ [[ "$GITHUB_SHA" =~ ^[a-f0-9]{40}$ ]]
285+ bunx trigger.dev@4.5.12 deploy --env preview --branch dev-sim --external-id "$GITHUB_SHA"
270286
271287 # Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR.
272288 # Runs in parallel with tests — only immutable sha tags are pushed here, and
@@ -384,95 +400,137 @@ jobs:
384400
385401 echo "tags=${TAGS}" >> $GITHUB_OUTPUT
386402
403+ - name : Reuse existing SHA artifact
404+ id : artifact
405+ if : matrix.ecr_repo_secret != ''
406+ env :
407+ ECR_REPO : ${{ steps.ecr-repo.outputs.name }}
408+ ECR_REGISTRY : ${{ steps.login-ecr.outputs.registry }}
409+ GHCR_IMAGE : ${{ matrix.ghcr_image }}
410+ run : |
411+ [[ "$GITHUB_SHA" =~ ^[a-f0-9]{40}$ ]] && test -n "$ECR_REPO"
412+ result="$(aws ecr batch-get-image --repository-name "$ECR_REPO" --image-ids "imageTag=$GITHUB_SHA" --output json)"
413+ jq -e 'all(.failures[]; .failureCode == "ImageNotFound")' <<< "$result" > /dev/null
414+ exists="$(jq -r '.images | length == 1' <<< "$result")"
415+ echo "exists=$exists" >> "$GITHUB_OUTPUT"
416+ if [ "$exists" = true ] && [ "$GITHUB_REF_NAME" = main ]; then
417+ digest="$(jq -r '.images[0].imageId.imageDigest' <<< "$result")"
418+ source="${ECR_REGISTRY}/${ECR_REPO}@${digest}"
419+ docker pull "$source"
420+ docker tag "$source" "${GHCR_IMAGE}:${GITHUB_SHA}-amd64"
421+ docker push "${GHCR_IMAGE}:${GITHUB_SHA}-amd64"
422+ fi
423+
387424 - name : Build and push images
388- if : steps.meta.outputs.skip != 'true'
425+ if : steps.meta.outputs.skip != 'true' && steps.artifact.outputs.exists != 'true'
389426 uses : ./.github/actions/docker-build
390427 with :
391428 provider : ${{ vars.CI_PROVIDER }}
392429 file : ${{ matrix.dockerfile }}
393430 platforms : linux/amd64
394431 tags : ${{ steps.meta.outputs.tags }}
432+ build-args : COMMIT_SHA=${{ github.sha }}
395433 max-cache-size-mb : ${{ matrix.cache_mb }}
396434
397- # Promote the sha-tagged ECR images to the deploy tags once tests and
398- # migrations pass. Pushing the ECR latest/staging tag is what triggers
399- # CodePipeline, so this seconds-long manifest retag is the deploy gate —
400- # the image builds themselves run in parallel with the tests. A single job
401- # (not a matrix) so all four sha manifests are verified before any tag
402- # moves; a missing image can't produce a partial mixed-version deploy.
435+ # Keep the existing ECR -> CodePipeline path; verify both release halves.
403436 promote-images :
404- name : Promote Images
405- needs : [migrate, build-amd64]
406- # Explicit results: see migrate's comment.
437+ name : Promote and Verify Release
438+ needs : [migrate, build-amd64, migrate-dev, build-dev, deploy-trigger-dev]
407439 if : >-
408- !cancelled() &&
409- needs.migrate.result == 'success' &&
410- needs.build-amd64.result == 'success' &&
411- github.event_name == 'push' &&
412- (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
440+ !cancelled() && github.event_name == 'push' &&
441+ ((github.ref == 'refs/heads/dev' &&
442+ needs.migrate-dev.result == 'success' && needs.build-dev.result == 'success' &&
443+ needs.deploy-trigger-dev.result == 'success') ||
444+ ((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') &&
445+ needs.migrate.result == 'success' && needs.build-amd64.result == 'success'))
413446 runs-on : ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
414- timeout-minutes : 10
447+ timeout-minutes : 130
415448 permissions :
416449 contents : read
450+ checks : read
417451 id-token : write
452+ outputs :
453+ decision : ${{ steps.guard.outputs.decision }}
454+ complete : ${{ steps.verify.outputs.complete }}
455+ env :
456+ GH_TOKEN : ${{ github.token }}
418457 steps :
419- - name : Configure AWS credentials
420- uses : aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
421- with :
422- role-to-assume : ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
423- aws-region : ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
458+ - name : Checkout code
459+ uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
424460
425- - name : Login to Amazon ECR
426- id : login-ecr
427- uses : aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2
461+ - name : Setup Bun
462+ uses : oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
463+ with :
464+ bun-version : 1.4.1
428465
429- # Deploy-tag moves must be monotonic: a re-run of an old run must never
430- # retag latest/staging back to stale code. A superseded first-attempt
431- # run still promotes — the ci-<ref> concurrency group executes runs
432- # serially in commit order, so an ancestor of head is a forward deploy.
433- - name : Guard against stale promotion
434- id : guard
466+ - name : Wait for matching worker deployment
435467 env :
436- GH_TOKEN : ${{ github.token }}
437- run : |
438- STATUS="$(gh api "repos/${{ github.repository }}/compare/${{ github.sha }}...${GITHUB_REF_NAME}" --jq '.status' || echo "unknown")"
439- if [ "$STATUS" = "identical" ] || { [ "$STATUS" = "ahead" ] && [ "${{ github.run_attempt }}" = "1" ]; }; then
440- echo "fresh=true" >> $GITHUB_OUTPUT
441- else
442- echo "::warning::Skipping promotion of ${{ github.sha }} (branch compare: ${STATUS}, attempt ${{ github.run_attempt }}). Moving the deploy tags here could deploy stale code; push a revert commit to roll back instead."
443- echo "fresh=false" >> $GITHUB_OUTPUT
444- fi
468+ TRIGGER_PROJECT_ID : ${{ secrets.TRIGGER_PROJECT_ID }}
469+ TRIGGER_DEPLOYMENT_VERSION : ${{ needs.deploy-trigger-dev.outputs.deployment_version }}
470+ run : bun scripts/verify-release.ts worker
471+
472+ - name : Configure AWS credentials
473+ uses : aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
474+ with :
475+ role-to-assume : ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
476+ aws-region : ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
477+ role-duration-seconds : 5400
445478
446- - name : Promote images to deploy tags
447- if : steps.guard.outputs.fresh == 'true'
479+ - name : Preflight all image artifacts
480+ id : images
448481 env :
482+ ECR_APP : ${{ secrets.ECR_APP }}
449483 ECR_REPOS : >-
450484 ${{ secrets.ECR_APP }}
451485 ${{ secrets.ECR_MIGRATIONS }}
452486 ${{ secrets.ECR_REALTIME }}
453487 ${{ secrets.ECR_PII }}
488+ run : bun scripts/verify-release.ts images
489+
490+ - name : Guard against stale promotion
491+ id : guard
492+ run : bun scripts/verify-release.ts freshness
493+
494+ - name : Promote changed images to deploy tags
495+ if : steps.guard.outputs.decision == 'promote'
496+ env :
497+ IMAGES : ${{ steps.images.outputs.images }}
454498 run : |
455- REGISTRY="${{ steps.login-ecr.outputs.registry }}"
499+ tag="$GITHUB_REF_NAME"
500+ [ "$tag" != main ] || tag=latest
501+ while IFS= read -r entry; do
502+ repo="$(jq -r '.repository' <<< "$entry")"
503+ jq -jr '.manifest' <<< "$entry" > "$RUNNER_TEMP/release-manifest.json"
504+ aws ecr put-image --repository-name "$repo" --image-tag "$tag" \
505+ --image-digest "$(jq -r '.sourceDigest' <<< "$entry")" \
506+ --image-manifest "file://$RUNNER_TEMP/release-manifest.json" \
507+ --image-manifest-media-type "$(jq -r '.mediaType' <<< "$entry")" > /dev/null
508+ echo "Promoted $repo:$tag" >> "$GITHUB_STEP_SUMMARY"
509+ done < <(jq -c '.[] | select(.promote)' <<< "$IMAGES")
510+
511+ - name : Verify app deployment
512+ id : verify
513+ if : steps.guard.outputs.decision == 'promote'
514+ env :
515+ APP_DIGEST : ${{ steps.images.outputs.app_digest }}
516+ APP_PLATFORM_DIGEST : ${{ steps.images.outputs.app_platform_digest }}
517+ run : bun scripts/verify-release.ts app
456518
457- if [ "${{ github.ref }}" = "refs/heads/main" ]; then
458- ECR_TAG="latest"
519+ - name : Record release outcome
520+ if : always()
521+ env :
522+ DECISION : ${{ steps.guard.outputs.decision }}
523+ COMPLETE : ${{ steps.verify.outputs.complete }}
524+ RESULT : ${{ job.status }}
525+ run : |
526+ if [ "$DECISION" = skip ]; then
527+ outcome=superseded
528+ elif [ "$RESULT" = success ] && [ "$COMPLETE" = true ]; then
529+ outcome=complete
459530 else
460- ECR_TAG="staging"
531+ outcome=incomplete
461532 fi
462-
463- # Verify every sha image exists before moving any deploy tag, so a
464- # missing/expired image aborts the whole promotion up front.
465- for repo in $ECR_REPOS; do
466- echo "🔍 Verifying ${repo}:${{ github.sha }}"
467- docker buildx imagetools inspect "${REGISTRY}/${repo}:${{ github.sha }}" > /dev/null
468- done
469-
470- for repo in $ECR_REPOS; do
471- echo "🚀 Promoting ${repo}:${{ github.sha }} to ${ECR_TAG}"
472- docker buildx imagetools create \
473- -t "${REGISTRY}/${repo}:${ECR_TAG}" \
474- "${REGISTRY}/${repo}:${{ github.sha }}"
475- done
533+ echo "Release $GITHUB_SHA ($GITHUB_REF_NAME): **$outcome**" >> "$GITHUB_STEP_SUMMARY"
476534
477535 # Build ARM64 images for GHCR (main branch only, runs in parallel with
478536 # tests). Pushes only the immutable sha tag — latest-arm64/version-arm64
@@ -534,6 +592,7 @@ jobs:
534592 file : ${{ matrix.dockerfile }}
535593 platforms : linux/arm64
536594 tags : ${{ matrix.image }}:${{ github.sha }}-arm64
595+ build-args : COMMIT_SHA=${{ github.sha }}
537596 max-cache-size-mb : ${{ matrix.cache_mb }}
538597
539598 # Publish all mutable GHCR tags (latest, latest-amd64/arm64, version tags)
@@ -548,6 +607,8 @@ jobs:
548607 if : >-
549608 !cancelled() &&
550609 needs.promote-images.result == 'success' &&
610+ needs.promote-images.outputs.decision == 'promote' &&
611+ needs.promote-images.outputs.complete == 'true' &&
551612 needs.build-ghcr-arm64.result == 'success' &&
552613 needs.detect-version.result == 'success' &&
553614 github.event_name == 'push' && github.ref == 'refs/heads/main'
@@ -583,12 +644,12 @@ jobs:
583644 env :
584645 GH_TOKEN : ${{ github.token }}
585646 run : |
586- STATUS ="$(gh api "repos/${{ github.repository }}/compare/${{ github.sha }}...${ GITHUB_REF_NAME}" --jq '.status' || echo "unknown" )"
587- if [ "$STATUS " = "identical" ] || { [ "$STATUS" = "ahead" ] && [ "${{ github.run_attempt }}" = "1" ]; } ; then
588- echo "fresh=true" >> $GITHUB_OUTPUT
647+ HEAD_SHA ="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${ GITHUB_REF_NAME}" --jq '.object.sha' )"
648+ if [ "$HEAD_SHA " = "$GITHUB_SHA" ] ; then
649+ echo "fresh=true" >> " $GITHUB_OUTPUT"
589650 else
590- echo "::warning::Publishing immutable tags for ${{ github.sha }} but skipping the latest tags (branch compare: ${STATUS}, attempt ${{ github.run_attempt }}) ."
591- echo "fresh=false" >> $GITHUB_OUTPUT
651+ echo "::warning::Skipping latest tags for superseded commit $GITHUB_SHA ."
652+ echo "fresh=false" >> " $GITHUB_OUTPUT"
592653 fi
593654
594655 - name : Publish tags and manifests
@@ -921,6 +982,8 @@ jobs:
921982 if : >-
922983 !cancelled() &&
923984 needs.promote-images.result == 'success' &&
985+ needs.promote-images.outputs.decision == 'promote' &&
986+ needs.promote-images.outputs.complete == 'true' &&
924987 needs.check-docs-changes.result == 'success' &&
925988 needs.check-docs-changes.outputs.docs_changed == 'true'
926989 uses : ./.github/workflows/docs-embeddings.yml
0 commit comments