desktop-release.yml6.0 KBView on GitHub

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 }}