name: CI # Runner provider toggle, read from the CI_PROVIDER repo variable: # # gh variable set CI_PROVIDER --body github # fall back to GitHub-hosted # gh variable delete CI_PROVIDER # back to Blacksmith (default) # # A repo variable, not a committed value: during a Blacksmith outage there is no # working CI to merge a switchover through. Only unset/'blacksmith' selects # Blacksmith; anything unrecognized selects GitHub so a typo can't queue jobs # against the provider you're escaping. Every runs-on and both composite actions # share this predicate and must change together. # # GitHub mode is break-glass, not a peer — cold layers, slower runs. The app image # is the one job on a paid larger runner: next build needs ~32 GB and OOM-kills # (exit 137) on the free 16 GB runners at any heap ceiling. 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: ...") # Smallest runner on purpose: a few seconds of pure shell over the commit # message, no checkout and no install. detect-version: name: Detect Version runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} 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 # Detect shell-code changes on dev/staging pushes. Web-only changes never # need a desktop build (installed shells load the web app live); changes to # the Electron app or the bridge packages trigger a per-env prerelease build # (dev → alpha channel, staging → beta) that the env's update feed # (/api/desktop/update) starts offering automatically. detect-desktop-changes: name: Detect Desktop Changes runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 5 if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/staging') outputs: changed: ${{ steps.diff.outputs.changed }} steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 50 - name: Diff desktop paths id: diff env: BEFORE: ${{ github.event.before }} run: | # Force pushes (dev resets) can reference a BEFORE we don't have; # fall back to the previous commit, and to no build when even that # is unavailable. if [ -z "$BEFORE" ] || ! git cat-file -e "$BEFORE" 2>/dev/null; then BEFORE="$(git rev-parse HEAD^ 2>/dev/null || echo '')" fi if [ -z "$BEFORE" ]; then echo "changed=false" >> "$GITHUB_OUTPUT" echo "ℹ️ No comparable base commit; skipping desktop prerelease" exit 0 fi if git diff --name-only "$BEFORE" HEAD | grep -qE '^(apps/desktop/|packages/desktop-bridge/|packages/browser-protocol/)'; then echo "changed=true" >> "$GITHUB_OUTPUT" echo "✅ Desktop shell code changed" else echo "changed=false" >> "$GITHUB_OUTPUT" echo "ℹ️ No desktop shell changes" 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] # Explicit need results instead of the implicit success(): a skipped job # anywhere in the transitive needs chain silently fails implicit success() # and cascade-skips the deploy chain (migrate -> promote-images -> # CodeDeploy) — this bit us on 2026-07-23. State requirements explicitly. if: >- !cancelled() && needs.test-build.result == 'success' && 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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 permissions: contents: read id-token: write strategy: fail-fast: false matrix: include: # Only the app image needs the paid 8-core/32 GB runner: next build # exhausts the free 16 GB one (exit 137). The others build in <5 min. # bs_runner mirrors that per-image sizing on Blacksmith — a single # pinned tier put every image on 8 vCPU, where the non-app builds idle # at 12-15% CPU and under 10% memory. - dockerfile: ./docker/app.Dockerfile ecr_repo_secret: ECR_APP gh_runner: linux-x64-8-core bs_runner: blacksmith-8vcpu-ubuntu-2404 - dockerfile: ./docker/db.Dockerfile ecr_repo_secret: ECR_MIGRATIONS gh_runner: ubuntu-latest bs_runner: blacksmith-2vcpu-ubuntu-2404 - dockerfile: ./docker/realtime.Dockerfile ecr_repo_secret: ECR_REALTIME gh_runner: ubuntu-latest bs_runner: blacksmith-4vcpu-ubuntu-2404 - dockerfile: ./docker/pii.Dockerfile ecr_repo_secret: ECR_PII gh_runner: ubuntu-latest bs_runner: blacksmith-4vcpu-ubuntu-2404 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: 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 || '' }} - name: Build and push uses: ./.github/actions/docker-build with: provider: ${{ vars.CI_PROVIDER }} file: ${{ matrix.dockerfile }} platforms: linux/amd64 tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev # Dev: deploy Trigger.dev background tasks to the preview "dev-sim" branch. # Gated after migrate-dev for the same reason as build-dev — the new task # code runs against the dev DB, so the schema must be pushed first. deploy-trigger-dev: name: Deploy Trigger.dev (Dev) needs: [migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.14 - 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 --ignore-scripts - name: Deploy to Trigger.dev working-directory: ./apps/sim env: TRIGGER_ACCESS_TOKEN: ${{ secrets.DEV_TRIGGER_ACCESS_TOKEN }} TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} run: | if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 exit 1 fi bunx trigger.dev@4.5.7 deploy --env preview --branch dev-sim # 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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} 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 gh_runner: linux-x64-8-core bs_runner: blacksmith-8vcpu-ubuntu-2404 - dockerfile: ./docker/db.Dockerfile ghcr_image: ghcr.io/simstudioai/migrations ecr_repo_secret: ECR_MIGRATIONS gh_runner: ubuntu-latest bs_runner: blacksmith-2vcpu-ubuntu-2404 - dockerfile: ./docker/realtime.Dockerfile ghcr_image: ghcr.io/simstudioai/realtime ecr_repo_secret: ECR_REALTIME gh_runner: ubuntu-latest bs_runner: blacksmith-4vcpu-ubuntu-2404 - dockerfile: ./docker/pii.Dockerfile ghcr_image: ghcr.io/simstudioai/pii ecr_repo_secret: ECR_PII gh_runner: ubuntu-latest bs_runner: blacksmith-4vcpu-ubuntu-2404 # No ECR repo is provisioned for cron, so it publishes to GHCR only. # The tag step below omits the ECR tag when the repo name is empty. - dockerfile: ./docker/cron.Dockerfile ghcr_image: ghcr.io/simstudioai/cron gh_runner: ubuntu-latest bs_runner: blacksmith-2vcpu-ubuntu-2404 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: 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="" if [ -n "$ECR_REPO" ]; then TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}" fi if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then if [ -n "$TAGS" ]; then TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64" else TAGS="${GHCR_IMAGE}:${{ github.sha }}-amd64" fi fi # An entry can legitimately resolve to no tags — e.g. the cron image has # no ECR repo, so on staging/dev (where GHCR tags are not applied) there # is nothing to push. Skip that build instead of failing the job. if [ -z "$TAGS" ]; then echo "No ECR repo and no GHCR tag for this entry on ${{ github.ref }} — skipping push." echo "skip=true" >> $GITHUB_OUTPUT else echo "skip=false" >> $GITHUB_OUTPUT fi echo "tags=${TAGS}" >> $GITHUB_OUTPUT - name: Build and push images if: steps.meta.outputs.skip != 'true' uses: ./.github/actions/docker-build with: provider: ${{ vars.CI_PROVIDER }} file: ${{ matrix.dockerfile }} platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} # 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] # Explicit results: see migrate's comment. if: >- !cancelled() && needs.migrate.result == 'success' && needs.build-amd64.result == 'success' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 permissions: contents: read id-token: write 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- 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 if: steps.guard.outputs.fresh == 'true' env: ECR_REPOS: >- ${{ secrets.ECR_APP }} ${{ secrets.ECR_MIGRATIONS }} ${{ secrets.ECR_REALTIME }} ${{ secrets.ECR_PII }} run: | REGISTRY="${{ steps.login-ecr.outputs.registry }}" if [ "${{ github.ref }}" = "refs/heads/main" ]; then ECR_TAG="latest" else ECR_TAG="staging" 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 # 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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read packages: write strategy: fail-fast: false matrix: # Non-app images sit at 4 vCPU rather than the finer x64 split: the ARM # sizing data is job-level (8 -> 4 for the whole matrix), not per-image, # and this job only runs on push to main — an unprovisioned label would # hang a release in `queued` rather than fail a PR. include: - dockerfile: ./docker/app.Dockerfile image: ghcr.io/simstudioai/simstudio gh_runner: linux-arm64-8-core bs_runner: blacksmith-8vcpu-ubuntu-2404-arm - dockerfile: ./docker/db.Dockerfile image: ghcr.io/simstudioai/migrations gh_runner: ubuntu-24.04-arm bs_runner: blacksmith-4vcpu-ubuntu-2404-arm - dockerfile: ./docker/realtime.Dockerfile image: ghcr.io/simstudioai/realtime gh_runner: ubuntu-24.04-arm bs_runner: blacksmith-4vcpu-ubuntu-2404-arm - dockerfile: ./docker/pii.Dockerfile image: ghcr.io/simstudioai/pii gh_runner: ubuntu-24.04-arm bs_runner: blacksmith-4vcpu-ubuntu-2404-arm - dockerfile: ./docker/cron.Dockerfile image: ghcr.io/simstudioai/cron gh_runner: ubuntu-24.04-arm bs_runner: blacksmith-4vcpu-ubuntu-2404-arm 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: Build and push ARM64 to GHCR uses: ./.github/actions/docker-build with: provider: ${{ vars.CI_PROVIDER }} file: ${{ matrix.dockerfile }} platforms: linux/arm64 tags: ${{ matrix.image }}:${{ github.sha }}-arm64 # 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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 needs: [promote-images, build-ghcr-arm64, detect-version] # Explicit results: see migrate's comment. if: >- !cancelled() && needs.promote-images.result == 'success' && needs.build-ghcr-arm64.result == 'success' && needs.detect-version.result == 'success' && 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 - image: ghcr.io/simstudioai/cron 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 # Smallest runner on purpose: a depth-2 checkout plus a path filter, no # install and no build. check-docs-changes: name: Check Docs Changes runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} 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] # Explicit results: see migrate's comment. if: >- !cancelled() && needs.promote-images.result == 'success' && needs.check-docs-changes.result == 'success' && 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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 needs: [create-ghcr-manifests, detect-version] # Explicit results: see migrate's comment. if: >- !cancelled() && needs.create-ghcr-manifests.result == 'success' && needs.detect-version.result == 'success' && 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.14 - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts - name: Create release env: GH_PAT: ${{ secrets.GITHUB_TOKEN }} run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }} # Desktop release: builds, signs, notarizes, and attaches the macOS app to # the GitHub release created above. Gated on the Apple signing secrets so a # release pipeline run skips cleanly (instead of failing) until the Apple # Developer account is provisioned — the moment the six secrets exist, the # next vX.Y.Z release ships desktop artifacts with no further changes. # Job-level `if:` cannot read the secrets context, hence the probe job. check-desktop-signing: name: Check Desktop Signing Secrets runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 2 needs: [detect-version, detect-desktop-changes] # !cancelled(): detect-desktop-changes is skipped on main (and # detect-version tags only on main); either path may need the probe. if: ${{ !cancelled() && (needs.detect-version.outputs.is_release == 'true' || needs.detect-desktop-changes.outputs.changed == 'true') }} outputs: configured: ${{ steps.check.outputs.configured }} steps: - name: Probe Apple signing secrets id: check env: CONFIGURED: ${{ secrets.CSC_LINK != '' && secrets.CSC_KEY_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER != '' && secrets.APPLE_TEAM_ID != '' }} run: | echo "configured=${CONFIGURED}" >> "$GITHUB_OUTPUT" if [ "$CONFIGURED" != "true" ]; then echo "::warning::Desktop release skipped: Apple signing secrets are not configured (CSC_LINK, CSC_KEY_PASSWORD, APPLE_API_KEY_P8, APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_TEAM_ID)." fi desktop-release: name: Desktop Release needs: [create-release, check-desktop-signing, detect-version] if: needs.check-desktop-signing.outputs.configured == 'true' permissions: contents: write uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.detect-version.outputs.version }} publish: true secrets: inherit # Per-env desktop prereleases: a dev/staging push that touches shell code # publishes a channel-tagged GitHub prerelease (vX.Y.Z-alpha.N from dev, # vX.Y.Z-beta.N from staging). Each environment's /api/desktop/update feed # offers only its channel, so dev-pointed shells pick up alpha builds, # staging-pointed shells beta builds, and prod-pointed shells stable # releases — independently. Unlike stable releases, prereleases build even # before the Apple signing secrets exist — unsigned, so the update pipeline # is testable end to end; installed shells detect the missing Developer ID # and offer a manual download instead of a Squirrel install. create-desktop-prerelease: name: Create Desktop Prerelease runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 5 needs: [detect-desktop-changes, check-desktop-signing] # Requires the signing probe to have actually succeeded (not just "not # cancelled") so a probe failure can't produce a release with no build. if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }} permissions: contents: write outputs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Compute prerelease version and create draft release id: version env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} SIGNED: ${{ needs.check-desktop-signing.outputs.configured }} run: | if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; APP_NAME="Sim Dev"; else CHANNEL=beta; APP_NAME="Sim Staging"; fi # Prerelease core = next patch after the latest stable release, so # channel builds always outrank the stable they are built on top of # and are always superseded by the next stable. The run-attempt # suffix keeps re-runs of the same workflow from colliding on the # tag while preserving semver ordering. # Fail loudly if the query itself fails: silently falling back to # v0.0.0 would publish a channel build that sorts below the shipped # stable, and installed shells would never see it as an update. if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then echo "::error::Could not query the latest stable release." exit 1 fi # An empty release list makes jq print "null", which ${VAR:-default} # does not treat as empty. Anything that is not a bare vX.Y.Z means # "no stable release to build on top of" — start the channel at 0.0.1. if [[ ! "$LATEST" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then LATEST="v0.0.0" fi IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}" TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}." if [ "$SIGNED" != "true" ]; then NOTES="$NOTES ⚠️ Unsigned test build (Apple signing secrets not configured). Gatekeeper will quarantine a downloaded copy: right-click → Open, or clear the flag with \`xattr -dr com.apple.quarantine \"/Applications/${APP_NAME}.app\"\`." fi # Draft until the build uploads its artifacts: drafts are invisible # to the update feed, so a failed or in-flight build can never take # the channel down with an assetless release. Publishing later also # defers tag creation, so failed builds strand no tags. gh release create "$TAG" \ --draft \ --prerelease \ --target "$GITHUB_SHA" \ --title "$TAG" \ --notes "$NOTES" echo "version=$TAG" >> "$GITHUB_OUTPUT" echo "✅ Created draft prerelease $TAG" desktop-prerelease: name: Desktop Prerelease Build needs: [create-desktop-prerelease, check-desktop-signing] permissions: contents: write uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.create-desktop-prerelease.outputs.version }} publish: true sign: ${{ needs.check-desktop-signing.outputs.configured == 'true' }} secrets: inherit # The draft only becomes visible to the update feed once its artifacts are # attached — this is what makes a dev/staging push atomic from the shell's # point of view. publish-desktop-prerelease: name: Publish Desktop Prerelease runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 5 needs: [create-desktop-prerelease, desktop-prerelease] permissions: contents: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} TAG: ${{ needs.create-desktop-prerelease.outputs.version }} steps: - name: Publish the draft release run: gh release edit "$TAG" --draft=false # Keep the release list tidy: per channel, retain the newest 5 prereleases # and delete the rest (with their tags, so dev force-resets don't strand # commits behind stale tags). Leftover drafts (failed or superseded builds) # are always garbage by this point — the current run's release is published. prune-desktop-prereleases: name: Prune Desktop Prereleases runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 5 needs: [publish-desktop-prerelease] permissions: contents: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - name: Delete stale prereleases run: | if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \ --jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNEL}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" | while read -r TAG; do [ -n "$TAG" ] || continue echo "Deleting stale prerelease $TAG" gh release delete "$TAG" --cleanup-tag --yes done - name: Delete leftover draft prereleases run: | if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi # Drafts have no tag ref, so delete by release id via the API # (gh release delete resolves by tag, which is ambiguous for drafts). gh api "repos/${GH_REPO}/releases?per_page=100" \ --jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNEL}\\\\.\"))) | .id" | while read -r ID; do [ -n "$ID" ] || continue echo "Deleting leftover draft release $ID" gh api -X DELETE "repos/${GH_REPO}/releases/${ID}" done