Introduced 1 production defect in 180 days, median 152 days to fix.
name: Desktop Release
on:
push:
branches:
- main
- staging
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Never cancel a release mid-flight: notarization and asset upload are not
# safely interruptible, and a cancelled run can leave a partial release.
cancel-in-progress: false
jobs:
# Cheap gate on ubuntu. Decides whether a release is needed based on whether
# the version's tag already exists, NOT on which paths a push touched. That
# makes a failed release self-healing: the next push to the branch retries it.
check:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
node_env: ${{ steps.version.outputs.node_env }}
should_release: ${{ steps.check.outputs.should_release }}
steps:
# workflow_dispatch accepts an arbitrary ref, and anything that is not
# `staging` is treated as production below. Without this guard a dispatch
# on any branch would check that branch out and hand its build scripts the
# signing, notarization, update-key, and publishing secrets. Refuse early,
# before any secret is referenced. `release` needs this job, so failing
# here stops the macOS job too.
- name: Guard release ref
run: |
REF='${{ github.ref_name }}'
if [ "$REF" != "main" ] && [ "$REF" != "staging" ]; then
echo "::error::Desktop Release may only run on main or staging. Refusing to run on '$REF'."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
- name: Read desktop version
id: version
run: |
VERSION=$(node -p "require('./apps/desktop/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [[ "${{ github.ref_name }}" == "staging" ]]; then
echo "tag=v${VERSION}-staging" >> $GITHUB_OUTPUT
echo "node_env=staging" >> $GITHUB_OUTPUT
else
echo "tag=v${VERSION}" >> $GITHUB_OUTPUT
echo "node_env=production" >> $GITHUB_OUTPUT
fi
- name: Check release status
id: check
env:
GH_TOKEN: ${{ secrets.RELEASES_REPO_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
RELEASES_REPO: CedarCopilot/cedar-releases
run: |
ERR=$(mktemp)
# Fail CLOSED. Only a definitive 404 means "not released yet". Any
# other failure (expired token, rate limit, 5xx, network) must stop
# the run. Treating those as "absent" is what let a dead token report
# exists=false, burn ~8 minutes of macOS build, and die at upload, and
# on a transient error it would re-upload over a good release's assets.
EXISTS=""
for attempt in 1 2 3; do
if gh api "repos/$RELEASES_REPO/releases/tags/$TAG" >/dev/null 2>"$ERR"; then
EXISTS=true
break
fi
if grep -q 'HTTP 404' "$ERR"; then
EXISTS=false
break
fi
if grep -qE 'HTTP 40(1|3)' "$ERR"; then
echo "::error::RELEASES_REPO_TOKEN cannot read $RELEASES_REPO ($(tr -d '\n' < "$ERR")). Mint a token with Contents: read and write, then update the secret."
exit 1
fi
echo "Attempt $attempt could not reach the releases API: $(tr -d '\n' < "$ERR")"
sleep $((attempt * 5))
done
if [ -z "$EXISTS" ]; then
echo "::error::Could not determine whether $TAG exists after 3 attempts. Refusing to build rather than risk overwriting an existing release. Last error: $(tr -d '\n' < "$ERR")"
exit 1
fi
if [ "$EXISTS" = "true" ]; then
echo "should_release=false" >> $GITHUB_OUTPUT
echo "Release $TAG already exists. Nothing to do."
exit 0
fi
# Only reached when we are about to build. Catches a read-only token
# here rather than after a full macOS build and notarization.
PUSH=$(gh api "repos/$RELEASES_REPO" --jq '.permissions.push // empty' 2>/dev/null || true)
if [ "$PUSH" = "false" ]; then
echo "::error::RELEASES_REPO_TOKEN can read $RELEASES_REPO but lacks write access. The publish step would fail after a full build."
exit 1
fi
echo "should_release=true" >> $GITHUB_OUTPUT
echo "Release $TAG does not exist. Building."
release:
needs: check
if: needs.check.outputs.should_release == 'true'
runs-on: macos-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Patch version for staging
if: needs.check.outputs.node_env == 'staging'
run: |
VERSION=${{ needs.check.outputs.version }}
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./apps/desktop/package.json', 'utf8'));
pkg.version = '${VERSION}-staging';
fs.writeFileSync('./apps/desktop/package.json', JSON.stringify(pkg, null, 2) + '\n');
"
- name: Install dependencies
run: pnpm install
- name: Build and publish
run: pnpm --filter @zero/desktop dist${{ needs.check.outputs.node_env == 'staging' && ':staging' || '' }}
env:
GH_TOKEN: ${{ secrets.RELEASES_REPO_TOKEN }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_ENV: ${{ needs.check.outputs.node_env }}
DESKTOP_UPDATE_KEY: ${{ secrets.DESKTOP_UPDATE_KEY }}