fix: address the review findings raised on the v0.8.21 release PR #30203
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 → dev stream, staging → staging) that the env's update feed | |
| # (/api/desktop/update) starts offering automatically. | |
| detect-desktop-changes: | |
| name: Detect Desktop 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/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 a large runner: next build exhausts the free | |
| # 16 GB one (exit 137). The others build in <5 min and idle at 12-15% | |
| # CPU on 8 vCPU, so they stay on the smaller tiers. | |
| # | |
| # 16 vCPU on Blacksmith because this build is the critical path to a | |
| # deploy — nothing ships until the image is pushed — and its two | |
| # dominant steps both scale with cores (`bun install` ~300-400s, `next | |
| # build` ~260s). The same `next build` runs on 16 vCPU in the separate | |
| # Build App verification job, which does not gate anything; this one | |
| # was doing comparable work on half the cores. | |
| # | |
| # cache_mb is the layer cache the post-job prune retains, and it is the | |
| # only reason the sticky disks stay bounded — see docker-build's | |
| # action.yml. Rows that omit it take the small-image default there. The | |
| # app image overrides because it carries ~34 layers plus apt and bun | |
| # cache mounts for the whole monorepo; 100 GB is several builds' worth | |
| # of headroom over that working set. | |
| - dockerfile: ./docker/app.Dockerfile | |
| cache_mb: '102400' | |
| ecr_repo_secret: ECR_APP | |
| gh_runner: linux-x64-8-core | |
| bs_runner: blacksmith-16vcpu-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-2vcpu-ubuntu-2404 | |
| - dockerfile: ./docker/pii.Dockerfile | |
| ecr_repo_secret: ECR_PII | |
| 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: ${{ 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 | |
| max-cache-size-mb: ${{ matrix.cache_mb }} | |
| # 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.12 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 | |
| cache_mb: '102400' | |
| ghcr_image: ghcr.io/simstudioai/simstudio | |
| ecr_repo_secret: ECR_APP | |
| gh_runner: linux-x64-8-core | |
| bs_runner: blacksmith-16vcpu-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-2vcpu-ubuntu-2404 | |
| - dockerfile: ./docker/pii.Dockerfile | |
| ghcr_image: ghcr.io/simstudioai/pii | |
| ecr_repo_secret: ECR_PII | |
| gh_runner: ubuntu-latest | |
| bs_runner: blacksmith-2vcpu-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 }} | |
| max-cache-size-mb: ${{ matrix.cache_mb }} | |
| # 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-<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 | |
| 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 | |
| cache_mb: '102400' | |
| 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 | |
| max-cache-size-mb: ${{ matrix.cache_mb }} | |
| # 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 | |
| # Every matrix leg evaluates the same guard against the same commit, so the | |
| # value is identical whichever leg reports it last. attest-subjects needs it | |
| # to tell "the guard withheld latest" from "the registry read was stale". | |
| outputs: | |
| latest_fresh: ${{ steps.guard.outputs.fresh }} | |
| 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 | |
| # Sign the published images and attach SLSA provenance and an SBOM to each. | |
| # | |
| # This runs after create-ghcr-manifests rather than inside the build because | |
| # buildx's own provenance/sbom attestations stay off (see the note in | |
| # .github/actions/docker-build): the extra manifests they add to an index | |
| # break the `imagetools create` retagging that promote-images depends on. | |
| # Attaching attestations here instead leaves the index itself untouched — they | |
| # are stored as separate referrer manifests that point at it. | |
| # | |
| # Resolve the set of digests that actually got published, so the attestation | |
| # job below covers every tag a customer can pull. | |
| # | |
| # A static list is not enough. `imagetools create` always writes an INDEX, so | |
| # `:<version>-amd64` is a single-entry index whose digest differs from the | |
| # `:<sha>-amd64` manifest it wraps — attesting the manifest leaves the tag | |
| # people actually pin unverifiable. Which tags exist also varies per run: | |
| # version tags only on a release, and the latest tags only when the monotonic | |
| # guard in create-ghcr-manifests passed. Resolving tag -> digest here and | |
| # de-duplicating is what keeps the two in step without hardcoding that logic | |
| # twice. | |
| attest-subjects: | |
| name: Resolve Attestation Subjects | |
| runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 10 | |
| needs: [create-ghcr-manifests, detect-version] | |
| if: >- | |
| !cancelled() && | |
| needs.create-ghcr-manifests.result == 'success' && | |
| needs.detect-version.result == 'success' && | |
| github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| permissions: | |
| contents: read | |
| packages: read | |
| outputs: | |
| subjects: ${{ steps.resolve.outputs.subjects }} | |
| count: ${{ steps.resolve.outputs.count }} | |
| steps: | |
| - name: Login to GHCR | |
| uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve published tags to digests | |
| id: resolve | |
| env: | |
| IS_RELEASE: ${{ needs.detect-version.outputs.is_release }} | |
| VERSION: ${{ needs.detect-version.outputs.version }} | |
| SHA: ${{ github.sha }} | |
| LATEST_FRESH: ${{ needs.create-ghcr-manifests.outputs.latest_fresh }} | |
| run: | | |
| set -euo pipefail | |
| IMAGES="simstudio migrations realtime pii cron" | |
| # Prints the digest, or nothing when the tag is genuinely absent. | |
| # | |
| # An absent tag and a registry hiccup both make `inspect` fail, and | |
| # treating them alike is how a published image silently ends up | |
| # unsigned while this job still goes green. So: retry, and only report | |
| # "absent" when the registry actually says the manifest is unknown. | |
| # Anything else fails the step. | |
| digest_of() { | |
| local ref="$1" attempt raw err absent=0 | |
| for attempt in 1 2 3; do | |
| if raw="$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' 2>/tmp/inspect.err)"; then | |
| printf '%s' "$raw" | jq -r '.digest // empty' | |
| return 0 | |
| fi | |
| err="$(cat /tmp/inspect.err)" | |
| # Absence is retried like any other failure: GHCR can report a | |
| # just-published alias as unknown for a moment, and accepting that | |
| # on the first attempt would skip a tag this run did publish. | |
| case "$err" in | |
| *"not found"*|*MANIFEST_UNKNOWN*|*"no such manifest"*|*"NAME_UNKNOWN"*) absent=1 ;; | |
| *) absent=0 ;; | |
| esac | |
| if [ "$attempt" -lt 3 ]; then | |
| sleep "$((attempt * 3))" | |
| fi | |
| done | |
| # Only call it absent if the registry said so on the final attempt. | |
| if [ "$absent" -eq 1 ]; then | |
| return 0 | |
| fi | |
| echo "::error::Could not inspect ${ref} after 3 attempts: ${err}" >&2 | |
| return 1 | |
| } | |
| # Records a subject. `platform` tells the attestation job whether this | |
| # digest is a single-architecture image, which is the only case where | |
| # a Syft SBOM describes what the puller actually gets. | |
| emit() { | |
| jq -nc --arg image "$1" --arg digest "$2" --arg platform "$3" \ | |
| '{image: $image, digest: $digest, platform: $platform}' >> /tmp/subjects.jsonl | |
| } | |
| : > /tmp/subjects.jsonl | |
| for name in $IMAGES; do | |
| image="ghcr.io/simstudioai/${name}" | |
| # The sha tags are this run's own output. All three must resolve — | |
| # a missing one means the publish did not complete, not that the tag | |
| # is optional. | |
| seen="" | |
| sha_index="" | |
| for tag in "${SHA}" "${SHA}-amd64" "${SHA}-arm64"; do | |
| digest="$(digest_of "${image}:${tag}")" | |
| if [ -z "$digest" ]; then | |
| echo "::error::${image}:${tag} was not published by this run" | |
| exit 1 | |
| fi | |
| case "$tag" in | |
| *-amd64) platform=amd64 ;; | |
| *-arm64) platform=arm64 ;; | |
| *) platform=index; sha_index="$digest" ;; | |
| esac | |
| seen="$seen $digest" | |
| emit "$image" "$digest" "$platform" | |
| done | |
| # A moving alias is only taken when it resolves to the same index | |
| # digest this run published — content identity, which is what a | |
| # digest can prove. create-ghcr-manifests holds the latest tags back when | |
| # its monotonic guard sees a newer commit, and they then still point | |
| # at an older build — attesting those would put this run's signature | |
| # and provenance on an image it did not produce. The per-arch | |
| # aliases are published in the same guarded block as `latest`, so | |
| # that one comparison gates all three. | |
| alias_groups="latest" | |
| if [ "${IS_RELEASE}" = "true" ]; then | |
| alias_groups="${alias_groups} ${VERSION}" | |
| fi | |
| for alias in $alias_groups; do | |
| # A mismatch has two very different causes: the guard deliberately | |
| # held the tag back, or GHCR is still serving the previous digest | |
| # moments after this run wrote it. Re-read before concluding the | |
| # former, or a read landing a second early silently drops three | |
| # subjects from the matrix. | |
| alias_index="" | |
| for alias_attempt in 1 2 3; do | |
| alias_index="$(digest_of "${image}:${alias}")" | |
| [ "$alias_index" = "$sha_index" ] && break | |
| [ "$alias_attempt" -lt 3 ] && sleep 5 || true | |
| done | |
| if [ "$alias_index" != "$sha_index" ]; then | |
| # `latest` is allowed to lag, but only when the guard actually | |
| # withheld it. If the guard published latest this run, a mismatch | |
| # here is a stale read, not a deliberate skip — and silently | |
| # dropping it would leave a published tag unsigned. | |
| if [ "$alias" = "latest" ]; then | |
| if [ "${LATEST_FRESH}" = "true" ]; then | |
| echo "::error::${image}:latest was published by this run but resolves to ${alias_index:-nothing}" | |
| exit 1 | |
| fi | |
| echo "Skipping latest* for ${image}: the monotonic guard withheld it this run." | |
| continue | |
| fi | |
| # A version tag has no such carve-out. This run published it, so | |
| # a release must not ship a version image nothing has attested. | |
| echo "::error::${image}:${alias} does not resolve to this run's index (${sha_index:-none}); refusing to publish an unattested release image" | |
| exit 1 | |
| fi | |
| for tag in "${alias}" "${alias}-amd64" "${alias}-arm64"; do | |
| digest="$(digest_of "${image}:${tag}")" | |
| if [ -z "$digest" ]; then | |
| echo "::error::${image}:${tag} is missing though ${image}:${alias} is current" | |
| exit 1 | |
| fi | |
| case " $seen " in *" $digest "*) continue ;; esac | |
| seen="$seen $digest" | |
| case "$tag" in | |
| *-amd64) emit "$image" "$digest" amd64 ;; | |
| *-arm64) emit "$image" "$digest" arm64 ;; | |
| *) emit "$image" "$digest" index ;; | |
| esac | |
| done | |
| done | |
| done | |
| if [ ! -s /tmp/subjects.jsonl ]; then | |
| echo "::error::Resolved no image digests to attest" | |
| exit 1 | |
| fi | |
| echo "Resolved $(wc -l < /tmp/subjects.jsonl) distinct subjects:" | |
| cat /tmp/subjects.jsonl | |
| echo "subjects=$(jq -sc . /tmp/subjects.jsonl)" >> "$GITHUB_OUTPUT" | |
| echo "count=$(wc -l < /tmp/subjects.jsonl | tr -d ' ')" >> "$GITHUB_OUTPUT" | |
| # One leg per distinct published digest. Attesting each subject separately is | |
| # also what makes the SBOMs truthful: the amd64 and arm64 images contain | |
| # different packages, and one SBOM attached to the index cannot describe both. | |
| attest-images: | |
| name: Attest Images | |
| runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 15 | |
| needs: [attest-subjects] | |
| if: >- | |
| !cancelled() && | |
| needs.attest-subjects.result == 'success' | |
| permissions: | |
| contents: read | |
| packages: write | |
| # Sigstore signs against the runner's OIDC identity; no key material is stored. | |
| id-token: write | |
| attestations: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: ${{ fromJSON(needs.attest-subjects.outputs.subjects) }} | |
| steps: | |
| - name: Login to GHCR | |
| uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # Skipped for index subjects: Syft resolves an index to one platform, so | |
| # the SBOM it produces would describe amd64 while the index also serves | |
| # arm64. The per-arch subjects below carry an accurate SBOM each, and the | |
| # index still gets a signature and provenance. | |
| # | |
| # Scanned by the `<sha>-<arch>` tag rather than by `matrix.digest`. Half | |
| # the per-arch subjects are single-entry INDEXES (`imagetools create` | |
| # writes an index even from one manifest), and Syft resolves an index | |
| # against the RUNNER's platform — so an arm64-only index fails outright on | |
| # an amd64 runner with "no child with platform linux/arm64". The sha tag is | |
| # the plain manifest that index wraps: identical content, no platform | |
| # resolution, and one pull shared by both subjects instead of two. | |
| - name: Generate SBOM | |
| if: matrix.platform != 'index' | |
| uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 | |
| with: | |
| image: ${{ matrix.image }}:${{ github.sha }}-${{ matrix.platform }} | |
| format: spdx-json | |
| output-file: sbom.spdx.json | |
| # The action's own release upload is for workflows triggered by a | |
| # release; these attach to the image instead. | |
| upload-artifact: false | |
| upload-release-assets: false | |
| - name: Attest SBOM | |
| if: matrix.platform != 'index' | |
| uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 | |
| with: | |
| subject-name: ${{ matrix.image }} | |
| subject-digest: ${{ matrix.digest }} | |
| sbom-path: sbom.spdx.json | |
| # Stored alongside the image so a mirrored registry carries the | |
| # attestation with it, rather than only being retrievable from GitHub. | |
| push-to-registry: true | |
| - name: Attest build provenance | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-name: ${{ matrix.image }} | |
| subject-digest: ${{ matrix.digest }} | |
| push-to-registry: true | |
| - name: Install Cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| # The attestations above prove how the image was built; this is the plain | |
| # signature that admission controllers (Kyverno, the Sigstore policy | |
| # controller) verify before admitting a pod. | |
| - name: Sign image | |
| run: cosign sign --yes "${{ matrix.image }}@${{ matrix.digest }}" | |
| # 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/**' | |
| - '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, attest-subjects, attest-images, detect-version] | |
| # Explicit results: see migrate's comment. attest-images is a gate, not just | |
| # an ordering edge — a release must not advertise images whose signature or | |
| # attestation failed to publish. The count check is belt and braces: today | |
| # attest-subjects already fails on an empty subject set, so this only bites | |
| # if that guard is ever removed. | |
| # | |
| # Note this gates the GitHub release, not the production deploy: CodePipeline | |
| # fires from the ECR tags moved by promote-images, upstream of this job, and | |
| # only the GHCR mirrors are attested. | |
| if: >- | |
| !cancelled() && | |
| needs.create-ghcr-manifests.result == 'success' && | |
| needs.attest-subjects.result == 'success' && | |
| needs.attest-subjects.outputs.count != '0' && | |
| needs.attest-images.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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| 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] | |
| # Suppress the implicit success() check: check-desktop-signing has an | |
| # intentionally skipped transitive dependency on main, which would | |
| # otherwise cascade-skip this job even when every direct need succeeded. | |
| if: >- | |
| !cancelled() && | |
| needs.create-release.result == 'success' && | |
| needs.check-desktop-signing.result == 'success' && | |
| needs.detect-version.result == 'success' && | |
| 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 an environment-tagged GitHub prerelease to the public, | |
| # release-only simstudioai/sim-desktop-releases repository. Keeping these | |
| # builds out of this source repository prevents its followers from receiving | |
| # every internal shell release. Each environment's /api/desktop/update feed | |
| # still offers only its own signed stream. | |
| create-desktop-prerelease: | |
| name: Create Desktop Prerelease | |
| runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| 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' && | |
| needs.check-desktop-signing.outputs.configured == 'true' | |
| }} | |
| permissions: | |
| contents: read | |
| 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: | |
| DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} | |
| GH_TOKEN: ${{ github.token }} | |
| PRERELEASE_REPOSITORY: simstudioai/sim-desktop-releases | |
| SOURCE_REPOSITORY: ${{ github.repository }} | |
| run: | | |
| if [ -z "$DESKTOP_RELEASE_TOKEN" ]; then | |
| echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases." | |
| exit 1 | |
| fi | |
| if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; else CHANNEL=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 --repo "$SOURCE_REPOSITORY" --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}." | |
| # 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. The release-only repo | |
| # has no source commit for this SHA, so its tag intentionally targets | |
| # that repository's main branch; the notes retain the source SHA. | |
| GH_TOKEN="$DESKTOP_RELEASE_TOKEN" gh release create "$TAG" \ | |
| --repo "$PRERELEASE_REPOSITORY" \ | |
| --draft \ | |
| --prerelease \ | |
| --target main \ | |
| --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] | |
| # The reusable workflow declares contents: write for its stable-release | |
| # path. GitHub cannot elevate a caller's token, even though this prerelease | |
| # path uses the dedicated cross-repository token for its actual upload. | |
| permissions: | |
| contents: write | |
| uses: ./.github/workflows/desktop-release.yml | |
| with: | |
| version: ${{ needs.create-desktop-prerelease.outputs.version }} | |
| publish: true | |
| sign: 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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 5 | |
| needs: [create-desktop-prerelease, desktop-prerelease] | |
| permissions: | |
| contents: read | |
| env: | |
| GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} | |
| GH_REPO: simstudioai/sim-desktop-releases | |
| TAG: ${{ needs.create-desktop-prerelease.outputs.version }} | |
| steps: | |
| - name: Publish the draft release | |
| run: | | |
| if [ -z "$GH_TOKEN" ]; then | |
| echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases." | |
| exit 1 | |
| fi | |
| 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: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 5 | |
| needs: [publish-desktop-prerelease] | |
| permissions: | |
| contents: read | |
| env: | |
| GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} | |
| GH_REPO: simstudioai/sim-desktop-releases | |
| steps: | |
| - name: Delete stale prereleases | |
| run: | | |
| if [ -z "$GH_TOKEN" ]; then | |
| echo "::error::DESKTOP_RELEASE_TOKEN is required to prune desktop prereleases." | |
| exit 1 | |
| fi | |
| if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi | |
| gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \ | |
| --jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | 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 CHANNELS='(dev|alpha)'; else CHANNELS='(staging|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(\"-${CHANNELS}\\\\.\"))) | .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 |