Skip to content

feat(self-hosting): sign and attest published images, and correct the self-hosting docs #30162

feat(self-hosting): sign and attest published images, and correct the self-hosting docs

feat(self-hosting): sign and attest published images, and correct the self-hosting docs #30162

Workflow file for this run

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