Skip to content

feat(ci): promote Trigger.dev tasks in lockstep with the ECS traffic cutover #22711

feat(ci): promote Trigger.dev tasks in lockstep with the ECS traffic cutover

feat(ci): promote Trigger.dev tasks in lockstep with the ECS traffic cutover #22711

Workflow file for this run

name: CI
on:
push:
branches: [main, staging, dev]
pull_request:
branches: [main, staging, dev]
# Docs content and markdown don't affect the app build or images; push
# runs stay unfiltered because they feed the deploy pipeline.
paths-ignore:
- 'apps/docs/content/**'
- '**/*.md'
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
test-build:
name: Test and Build
if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request'
uses: ./.github/workflows/test-build.yml
secrets: inherit
# Detect if this is a version release commit (e.g., "v0.5.24: ...")
detect-version:
name: Detect Version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
outputs:
version: ${{ steps.extract.outputs.version }}
is_release: ${{ steps.extract.outputs.is_release }}
steps:
- name: Extract version from commit message
id: extract
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
# Only tag versions on main branch
if [ "$GITHUB_REF" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
VERSION="${BASH_REMATCH[1]}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "is_release=true" >> $GITHUB_OUTPUT
echo "✅ Detected release commit: ${VERSION}"
else
echo "version=" >> $GITHUB_OUTPUT
echo "is_release=false" >> $GITHUB_OUTPUT
echo "ℹ️ Not a release commit"
fi
# Run database migrations before images are promoted: the ECR latest/staging
# tag push triggers CodePipeline, so migrating first guarantees the schema is
# in place before the new app version deploys (replaces the removed ECS
# migration sidecar)
migrate:
name: Migrate DB
needs: [test-build]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
uses: ./.github/workflows/migrations.yml
with:
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
secrets: inherit
# Same ordering for dev (schema push before the dev image lands in ECR)
migrate-dev:
name: Migrate Dev DB
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
uses: ./.github/workflows/migrations.yml
with:
environment: dev
secrets: inherit
# Dev: build all 3 images for ECR only (no GHCR, no ARM64)
build-dev:
name: Build Dev ECR
needs: [detect-version, migrate-dev]
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
include:
- dockerfile: ./docker/app.Dockerfile
ecr_repo_secret: ECR_APP
- dockerfile: ./docker/db.Dockerfile
ecr_repo_secret: ECR_MIGRATIONS
- dockerfile: ./docker/realtime.Dockerfile
ecr_repo_secret: ECR_REALTIME
- dockerfile: ./docker/pii.Dockerfile
ecr_repo_secret: ECR_PII
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
with:
role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ secrets.DEV_AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Resolve ECR repo name
id: ecr-repo
run: echo "name=$ECR_REPO" >> $GITHUB_OUTPUT
env:
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 || '' }}
# App leg only: capture the digest the :dev tag currently points at, BEFORE
# this build overwrites it, so promote-trigger-dev can tell whether the app
# image actually changed (a no-op :dev push triggers no ECS deploy).
- name: Capture previous :dev app digest
id: prevdigest
if: matrix.ecr_repo_secret == 'ECR_APP'
run: |
REF="${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev"
PREV=$(docker buildx imagetools inspect "$REF" 2>/dev/null | awk '/^Digest:/{print $2; exit}' || true)
echo "digest=${PREV}" >> "$GITHUB_OUTPUT"
- name: Build and push
id: build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64
push: true
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev
provenance: false
sbom: false
# App leg only: publish the metadata promote-trigger-dev needs to correlate
# this push to its dev ECS deploy and decide whether to wait. Dev has no
# promote-images job, so this stands in for its retag_epoch/app_image_changed
# outputs. The epoch is recorded just after the :dev push (the pipeline trigger).
- name: Publish dev cutover metadata
if: matrix.ecr_repo_secret == 'ECR_APP'
run: |
mkdir -p dev-meta
NEW="${{ steps.build.outputs.digest }}"
PREV="${{ steps.prevdigest.outputs.digest }}"
echo "$NEW" > dev-meta/digest.txt
date +%s > dev-meta/retag_epoch.txt
if [ -n "$NEW" ] && [ "$NEW" = "$PREV" ]; then
echo "false" > dev-meta/app_image_changed.txt
echo "ℹ️ :dev already points at ${NEW}; no ECS dev deploy will be triggered."
else
echo "true" > dev-meta/app_image_changed.txt
fi
- name: Upload dev cutover metadata
if: matrix.ecr_repo_secret == 'ECR_APP'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dev-cutover-meta
path: dev-meta/
retention-days: 1
# Dev: build & upload the Trigger.dev task version WITHOUT promoting it
# (--skip-promotion) to the preview "dev-sim" branch. promote-trigger-dev flips
# it at the dev ECS traffic cutover. Gated after migrate-dev so the schema is
# pushed before the new task version can run against the dev DB.
deploy-trigger-dev:
name: Deploy Trigger.dev (Dev)
needs: [migrate-dev]
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
outputs:
version: ${{ steps.deploy.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Deploy to Trigger.dev (skip promotion)
id: deploy
working-directory: ./apps/sim
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
run: |
set -eo pipefail
if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then
echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2
exit 1
fi
bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim --skip-promotion 2>&1 | tee deploy.log
VERSION=$(sed -E 's/\x1b\[[0-9;]*m//g' deploy.log | grep -oE '20[0-9]{6}\.[0-9]+' | tail -n1 || true)
if [ -z "$VERSION" ]; then
echo "ERROR: could not parse deployed version from deploy output" >&2
exit 1
fi
echo "Captured deployed version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Dev: promote the skip-promoted preview version at the dev ECS traffic cutover.
# Dev has no promote-images gate (build-dev pushes :dev directly), so the digest,
# trigger epoch, and app-image-changed signal come from build-dev's artifact.
# trigger.dev supports promoting a specific preview branch: promote --env preview
# --branch dev-sim.
promote-trigger-dev:
name: Promote Trigger.dev (Dev)
needs: [build-dev, deploy-trigger-dev]
# Run as long as the task upload succeeded, even if a NON-app build-dev leg
# (realtime/pii/migrations) failed: the app leg pushes :dev independently and
# may have already triggered the ECS deploy, so an unrelated image failure must
# not strand the app on the old task version. The app-metadata artifact (only
# the app leg uploads it) is the real signal that an app deploy happened.
if: >-
!cancelled() &&
github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
needs.deploy-trigger-dev.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
# Dev bake is 5 min; the poll budget (30 min) and session (40 min) are sized for
# that with margin, well short of the prod path's 70/90.
timeout-minutes: 40
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
# Tolerate a missing artifact: it's only uploaded by the app leg, so its
# absence means the app image didn't build → no ECS deploy happened.
- name: Download dev cutover metadata
id: meta
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: dev-cutover-meta
path: dev-meta
- name: Determine whether an app deploy happened
id: appdeploy
run: |
if [ -f dev-meta/app_image_changed.txt ]; then
echo "deployed=true" >> "$GITHUB_OUTPUT"
else
echo "deployed=false" >> "$GITHUB_OUTPUT"
echo "::warning::No app-image metadata (app leg did not build); skipping dev task promotion."
fi
- name: Configure AWS credentials
if: steps.appdeploy.outputs.deployed == 'true'
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
with:
role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ secrets.DEV_AWS_REGION }}
role-duration-seconds: 2400
- name: Wait for ECS traffic cutover
if: steps.appdeploy.outputs.deployed == 'true'
env:
OVERALL_TIMEOUT: "1800"
run: |
set -eo pipefail
CHANGED=$(cat dev-meta/app_image_changed.txt)
if [ "$CHANGED" != "true" ]; then
echo "App image unchanged — no dev ECS deploy triggered; promoting immediately."
exit 0
fi
DIGEST=$(cat dev-meta/digest.txt)
EPOCH=$(cat dev-meta/retag_epoch.txt)
bash .github/scripts/wait-for-ecs-cutover.sh sim-dev-us-east-1-app-deployment "$DIGEST" "$EPOCH"
- name: Promote Trigger.dev version
if: steps.appdeploy.outputs.deployed == 'true'
working-directory: ./apps/sim
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
VERSION: ${{ needs.deploy-trigger-dev.outputs.version }}
run: |
set -eo pipefail
if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then
echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2
exit 1
fi
if [ -z "$VERSION" ]; then
echo "ERROR: no deployed version passed from deploy-trigger-dev" >&2
exit 1
fi
echo "Promoting Trigger.dev version $VERSION (preview / dev-sim)"
bunx trigger.dev@4.4.3 promote "$VERSION" --env preview --branch dev-sim
# Main/staging: build & upload the Trigger.dev task version WITHOUT promoting it
# (--skip-promotion). New runs keep executing the OLD promoted version until
# promote-trigger flips it at the ECS traffic cutover — so the app cutting over
# never changes which task version runs until promote-trigger (which depends on
# this job) promotes the version uploaded here. Runs in parallel with the build;
# intentionally NOT gating the app deploy on it, to avoid coupling every app /
# realtime / pii / migration deploy to trigger.dev availability.
deploy-trigger:
name: Deploy Trigger.dev
needs: [migrate]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
outputs:
version: ${{ steps.deploy.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Deploy to Trigger.dev (skip promotion)
id: deploy
working-directory: ./apps/sim
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
TRIGGER_ENV: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }}
run: |
set -eo pipefail
if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then
echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2
exit 1
fi
bunx trigger.dev@4.4.3 deploy --env "$TRIGGER_ENV" --skip-promotion 2>&1 | tee deploy.log
# Extract the deployed version (e.g. 20260715.2) tied to THIS invocation.
VERSION=$(sed -E 's/\x1b\[[0-9;]*m//g' deploy.log | grep -oE '20[0-9]{6}\.[0-9]+' | tail -n1 || true)
if [ -z "$VERSION" ]; then
echo "ERROR: could not parse deployed version from deploy output" >&2
exit 1
fi
echo "Captured deployed version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR.
# Runs in parallel with tests — only immutable sha tags are pushed here, and
# the CodePipeline EventBridge triggers filter on exactly the
# latest/staging/dev ECR tags, so nothing deploys and no mutable tag moves
# until promote-images / create-ghcr-manifests retag after the gate.
build-amd64:
name: Build AMD64
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
include:
- dockerfile: ./docker/app.Dockerfile
ghcr_image: ghcr.io/simstudioai/simstudio
ecr_repo_secret: ECR_APP
- dockerfile: ./docker/db.Dockerfile
ghcr_image: ghcr.io/simstudioai/migrations
ecr_repo_secret: ECR_MIGRATIONS
- dockerfile: ./docker/realtime.Dockerfile
ghcr_image: ghcr.io/simstudioai/realtime
ecr_repo_secret: ECR_REALTIME
- dockerfile: ./docker/pii.Dockerfile
ghcr_image: ghcr.io/simstudioai/pii
ecr_repo_secret: ECR_PII
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
if: github.ref == 'refs/heads/main'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Resolve ECR repo name
id: ecr-repo
run: echo "name=$ECR_REPO" >> $GITHUB_OUTPUT
env:
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 || '' }}
# Only sha tags here — the ECR deploy tags (latest/staging) are applied
# by promote-images and the GHCR latest-amd64/version tags by
# create-ghcr-manifests, both after tests and migrations pass.
- name: Generate tags
id: meta
run: |
ECR_REGISTRY="${{ steps.login-ecr.outputs.registry }}"
ECR_REPO="${{ steps.ecr-repo.outputs.name }}"
GHCR_IMAGE="${{ matrix.ghcr_image }}"
TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}"
if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then
TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64"
fi
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
- name: Build and push images
id: build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
provenance: false
sbom: false
# Publish the app image digest so promote-trigger-* can correlate this push
# to its ECS CodePipeline execution. promote-images retags this same sha
# image to latest/staging (preserving the digest), so the pipeline's ECR
# source revision equals this digest — the only durable key (the deploy tag
# is floating). App leg only.
- name: Publish app image digest
if: matrix.ecr_repo_secret == 'ECR_APP'
run: |
mkdir -p digest
echo "${{ steps.build.outputs.digest }}" > digest/app-image-digest.txt
- name: Upload app image digest
if: matrix.ecr_repo_secret == 'ECR_APP'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: app-image-digest
path: digest/app-image-digest.txt
retention-days: 1
# Promote the sha-tagged ECR images to the deploy tags once tests and
# migrations pass. Pushing the ECR latest/staging tag is what triggers
# CodePipeline, so this seconds-long manifest retag is the deploy gate —
# the image builds themselves run in parallel with the tests. A single job
# (not a matrix) so all four sha manifests are verified before any tag
# moves; a missing image can't produce a partial mixed-version deploy.
promote-images:
name: Promote Images
needs: [migrate, build-amd64]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
permissions:
contents: read
id-token: write
outputs:
# Whether the deploy tag was actually moved (false on a stale-run guard
# skip). promote-trigger keys off this so tasks are never promoted when
# the app itself wasn't.
promoted: ${{ steps.guard.outputs.fresh }}
# Epoch when the deploy tag was retagged (this push's ECS pipeline trigger).
# promote-trigger passes it to the poll script so a stale pipeline execution
# reusing the same image digest can't satisfy the cutover gate.
retag_epoch: ${{ steps.promote.outputs.retag_epoch }}
# 'false' when the app deploy tag didn't move to a new digest (no ECS deploy).
# promote-trigger promotes immediately in that case instead of waiting.
app_image_changed: ${{ steps.promote.outputs.app_image_changed }}
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2
# Deploy-tag moves must be monotonic: a re-run of an old run must never
# retag latest/staging back to stale code. A superseded first-attempt
# run still promotes — the ci-<ref> concurrency group executes runs
# serially in commit order, so an ancestor of head is a forward deploy.
- name: Guard against stale promotion
id: guard
env:
GH_TOKEN: ${{ github.token }}
run: |
STATUS="$(gh api "repos/${{ github.repository }}/compare/${{ github.sha }}...${GITHUB_REF_NAME}" --jq '.status' || echo "unknown")"
if [ "$STATUS" = "identical" ] || { [ "$STATUS" = "ahead" ] && [ "${{ github.run_attempt }}" = "1" ]; }; then
echo "fresh=true" >> $GITHUB_OUTPUT
else
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."
echo "fresh=false" >> $GITHUB_OUTPUT
fi
- name: Promote images to deploy tags
id: promote
if: steps.guard.outputs.fresh == 'true'
env:
ECR_REPOS: >-
${{ secrets.ECR_APP }}
${{ secrets.ECR_MIGRATIONS }}
${{ secrets.ECR_REALTIME }}
${{ secrets.ECR_PII }}
run: |
# Record the retag time BEFORE moving any tag — this is when the ECS
# pipeline for this push is triggered. promote-trigger uses it to
# reject an older pipeline execution reusing the same image digest.
echo "retag_epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
REGISTRY="${{ steps.login-ecr.outputs.registry }}"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
ECR_TAG="latest"
else
ECR_TAG="staging"
fi
# Detect whether the APP deploy tag actually moves to a new digest. If
# this commit's app image is byte-identical to the currently-deployed one
# (e.g. a commit that doesn't touch the app image — docs/CI-only), the
# retag is a no-op, ECR fires no push event, and no ECS app deploy runs.
# promote-trigger reads this to promote immediately instead of waiting for
# a cutover that will never happen.
get_digest() { docker buildx imagetools inspect "$1" 2>/dev/null | awk '/^Digest:/{print $2; exit}'; }
APP_REF="${REGISTRY}/${{ secrets.ECR_APP }}"
NEW_APP_DIGEST="$(get_digest "${APP_REF}:${{ github.sha }}")"
PREV_APP_DIGEST="$(get_digest "${APP_REF}:${ECR_TAG}" || true)"
if [ -n "$NEW_APP_DIGEST" ] && [ "$NEW_APP_DIGEST" = "$PREV_APP_DIGEST" ]; then
echo "app_image_changed=false" >> "$GITHUB_OUTPUT"
echo "ℹ️ App deploy tag ${ECR_TAG} already points at ${NEW_APP_DIGEST}; no ECS app deploy will be triggered."
else
echo "app_image_changed=true" >> "$GITHUB_OUTPUT"
fi
# Verify every sha image exists before moving any deploy tag, so a
# missing/expired image aborts the whole promotion up front.
for repo in $ECR_REPOS; do
echo "🔍 Verifying ${repo}:${{ github.sha }}"
docker buildx imagetools inspect "${REGISTRY}/${repo}:${{ github.sha }}" > /dev/null
done
for repo in $ECR_REPOS; do
echo "🚀 Promoting ${repo}:${{ github.sha }} to ${ECR_TAG}"
docker buildx imagetools create \
-t "${REGISTRY}/${repo}:${ECR_TAG}" \
"${REGISTRY}/${repo}:${{ github.sha }}"
done
# Main/staging: promote the skip-promoted Trigger.dev version at the exact moment
# the ECS app deploy shifts traffic (CodeDeploy AllowTraffic on every target), so
# tasks and app cut over in lockstep. The promote-images retag is what triggers
# the ECS pipeline; this job correlates it via the app image digest + retag epoch
# (rejecting a stale execution reusing the digest) and promotes at cutover.
# Skipped when promote-images skipped the tag move (stale run) — tasks then
# correctly stay on the old version. If the app deploy fails or never cuts over,
# promote never fires and this job fails visibly.
promote-trigger:
name: Promote Trigger.dev
needs: [promote-images, deploy-trigger]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') &&
needs.promote-images.outputs.promoted == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
# Must exceed the poll script's OVERALL_TIMEOUT (70 min, covering a prod deploy
# queued behind a ~50-min bake) PLUS runner setup + the final promote step, so
# the Actions timeout never kills the job before the script's own deadline.
timeout-minutes: 90
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Download app image digest
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: app-image-digest
path: digest
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
# The poll can run up to ~70 min (prod deploy queued behind a bake), which
# outlasts the default 1h session. Hold the session for the full job so AWS
# calls don't start failing mid-poll. Requires the deploy role's
# MaxSessionDuration to be >= this value (roles are managed outside the repo).
role-duration-seconds: 5400
# Skip the cutover wait when the app image didn't change (no ECS deploy was
# triggered) — otherwise the poll would hang until timeout. Promotion still
# runs below, immediately, since there is no app cutover to align with.
- name: Wait for ECS traffic cutover
if: needs.promote-images.outputs.app_image_changed == 'true'
env:
PIPELINE: sim-${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}-us-east-1-app-deployment
RETAG_EPOCH: ${{ needs.promote-images.outputs.retag_epoch }}
run: |
set -eo pipefail
DIGEST=$(cat digest/app-image-digest.txt)
bash .github/scripts/wait-for-ecs-cutover.sh "$PIPELINE" "$DIGEST" "$RETAG_EPOCH"
- name: Promote Trigger.dev version
working-directory: ./apps/sim
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
TRIGGER_ENV: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }}
VERSION: ${{ needs.deploy-trigger.outputs.version }}
run: |
set -eo pipefail
if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then
echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2
exit 1
fi
if [ -z "$VERSION" ]; then
echo "ERROR: no deployed version passed from deploy-trigger" >&2
exit 1
fi
echo "Promoting Trigger.dev version $VERSION ($TRIGGER_ENV)"
bunx trigger.dev@4.4.3 promote "$VERSION" --env "$TRIGGER_ENV"
# Build ARM64 images for GHCR (main branch only, runs in parallel with
# tests). Pushes only the immutable sha tag — latest-arm64/version-arm64
# are applied by create-ghcr-manifests after the gate, so a failing run
# never moves a documented tag.
build-ghcr-arm64:
name: Build ARM64 (GHCR Only)
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
timeout-minutes: 30
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- dockerfile: ./docker/app.Dockerfile
image: ghcr.io/simstudioai/simstudio
- dockerfile: ./docker/db.Dockerfile
image: ghcr.io/simstudioai/migrations
- dockerfile: ./docker/realtime.Dockerfile
image: ghcr.io/simstudioai/realtime
- dockerfile: ./docker/pii.Dockerfile
image: ghcr.io/simstudioai/pii
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Build and push ARM64 to GHCR
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/arm64
push: true
tags: ${{ matrix.image }}:${{ github.sha }}-arm64
provenance: false
sbom: false
# Publish all mutable GHCR tags (latest, latest-amd64/arm64, version tags)
# and the multi-arch manifests from the immutable sha tags — only on main,
# after the deploy gate (promote-images) and the ARM64 build both pass.
create-ghcr-manifests:
name: Create GHCR Manifests
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
needs: [promote-images, build-ghcr-arm64, detect-version]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- image: ghcr.io/simstudioai/simstudio
- image: ghcr.io/simstudioai/migrations
- image: ghcr.io/simstudioai/realtime
- image: ghcr.io/simstudioai/pii
steps:
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Same monotonic guard as promote-images, applied to the public latest
# tags only — immutable sha and version tags are always published.
- name: Guard against stale latest tags
id: guard
env:
GH_TOKEN: ${{ github.token }}
run: |
STATUS="$(gh api "repos/${{ github.repository }}/compare/${{ github.sha }}...${GITHUB_REF_NAME}" --jq '.status' || echo "unknown")"
if [ "$STATUS" = "identical" ] || { [ "$STATUS" = "ahead" ] && [ "${{ github.run_attempt }}" = "1" ]; }; then
echo "fresh=true" >> $GITHUB_OUTPUT
else
echo "::warning::Publishing immutable tags for ${{ github.sha }} but skipping the latest tags (branch compare: ${STATUS}, attempt ${{ github.run_attempt }})."
echo "fresh=false" >> $GITHUB_OUTPUT
fi
- name: Publish tags and manifests
run: |
IMAGE="${{ matrix.image }}"
SHA="${{ github.sha }}"
# Multi-arch manifest from the immutable per-arch sha tags
docker buildx imagetools create -t "${IMAGE}:${SHA}" \
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then
VERSION="${{ needs.detect-version.outputs.version }}"
echo "📦 Publishing version tags: ${VERSION}"
docker buildx imagetools create -t "${IMAGE}:${VERSION}-amd64" "${IMAGE}:${SHA}-amd64"
docker buildx imagetools create -t "${IMAGE}:${VERSION}-arm64" "${IMAGE}:${SHA}-arm64"
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
fi
if [ "${{ steps.guard.outputs.fresh }}" = "true" ]; then
docker buildx imagetools create -t "${IMAGE}:latest-amd64" "${IMAGE}:${SHA}-amd64"
docker buildx imagetools create -t "${IMAGE}:latest-arm64" "${IMAGE}:${SHA}-arm64"
docker buildx imagetools create -t "${IMAGE}:latest" \
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
fi
# Check if docs changed
check-docs-changes:
name: Check Docs Changes
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
docs_changed: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 2 # Need at least 2 commits to detect changes
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
id: filter
with:
filters: |
docs:
- 'apps/docs/content/docs/en/**'
- 'apps/sim/scripts/process-docs.ts'
- 'apps/sim/lib/chunkers/**'
# Process docs embeddings (only when docs change, after images are promoted)
process-docs:
name: Process Docs
needs: [promote-images, check-docs-changes]
if: needs.check-docs-changes.outputs.docs_changed == 'true'
uses: ./.github/workflows/docs-embeddings.yml
secrets: inherit
# Create GitHub Release (only for version commits on main, after all builds complete)
create-release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
needs: [create-ghcr-manifests, detect-version]
if: needs.detect-version.outputs.is_release == 'true'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Create release
env:
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }}