0.4.2 #101
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: Release macOS App | |
| on: | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - package.json | |
| - src-tauri/tauri.conf.json | |
| workflow_dispatch: | |
| concurrency: | |
| group: macos-release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| jobs: | |
| release: | |
| name: macOS Apple Silicon | |
| runs-on: macos-latest | |
| timeout-minutes: 45 | |
| env: | |
| HEADROOM_UPDATER_ENDPOINTS: '["https://github.com/gglucass/headroom-desktop/releases/latest/download/latest.json"]' | |
| HEADROOM_UPDATER_STAGING_ENDPOINTS: '["https://github.com/gglucass/headroom-desktop/releases/download/staging-rolling/latest.json"]' | |
| HEADROOM_UPDATER_PUBLIC_KEY: ${{ vars.HEADROOM_UPDATER_PUBLIC_KEY }} | |
| APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} | |
| APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} | |
| APPLE_API_PRIVATE_KEY_P8: ${{ secrets.APPLE_API_PRIVATE_KEY_P8 }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| HEADROOM_ACCOUNT_API_BASE_URL: ${{ vars.HEADROOM_ACCOUNT_API_BASE_URL }} | |
| HEADROOM_APTABASE_APP_KEY: ${{ vars.HEADROOM_APTABASE_APP_KEY }} | |
| VITE_HEADROOM_SALES_CONTACT_URL: ${{ vars.VITE_HEADROOM_SALES_CONTACT_URL }} | |
| VITE_HEADROOM_CONTACT_FORM_URL: ${{ vars.VITE_HEADROOM_CONTACT_FORM_URL }} | |
| VITE_CLARITY_PROJECT_ID: ${{ vars.VITE_CLARITY_PROJECT_ID }} | |
| HEADROOM_SENTRY_DSN: ${{ secrets.HEADROOM_SENTRY_DSN }} | |
| VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: aarch64-apple-darwin | |
| - name: Install cargo-nextest | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: nextest | |
| - name: Cache Rust build | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: | | |
| src-tauri -> target | |
| env-vars: HEADROOM_SENTRY_DSN | |
| - name: Validate release version state | |
| id: release_guard | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| package_version="$(node -p "require('./package.json').version")" | |
| tauri_version="$(node -p "JSON.parse(require('fs').readFileSync('./src-tauri/tauri.conf.json', 'utf8')).version")" | |
| expected_tag="v${package_version}" | |
| should_release=true | |
| if [[ "$package_version" != "$tauri_version" ]]; then | |
| echo "package.json version ($package_version) does not match src-tauri/tauri.conf.json version ($tauri_version)." >&2 | |
| exit 1 | |
| fi | |
| if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then | |
| before_sha="${{ github.event.before }}" | |
| if [[ -n "${before_sha}" ]] && [[ "${before_sha}" != "0000000000000000000000000000000000000000" ]] && git cat-file -e "${before_sha}^{commit}" 2>/dev/null; then | |
| previous_package_version="$( | |
| git show "${before_sha}:package.json" \ | |
| | node -e "const fs = require('fs'); console.log(JSON.parse(fs.readFileSync(0, 'utf8')).version);" | |
| )" | |
| previous_tauri_version="$( | |
| git show "${before_sha}:src-tauri/tauri.conf.json" \ | |
| | node -e "const fs = require('fs'); console.log(JSON.parse(fs.readFileSync(0, 'utf8')).version);" | |
| )" | |
| if [[ "${previous_package_version}" == "${package_version}" ]] && [[ "${previous_tauri_version}" == "${tauri_version}" ]]; then | |
| should_release=false | |
| echo "No app version bump detected relative to ${before_sha}; skipping release." | |
| fi | |
| fi | |
| fi | |
| if [[ "${GITHUB_REF}" == refs/tags/* ]]; then | |
| if [[ "${GITHUB_REF_NAME}" != "$expected_tag" ]]; then | |
| echo "Git tag ${GITHUB_REF_NAME} does not match app version ${expected_tag}." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Stable release requires a plain X.Y.Z version (got: $package_version). Pre-release versions ship via the staging workflow." >&2 | |
| exit 1 | |
| fi | |
| echo "package_version=${package_version}" >> "$GITHUB_OUTPUT" | |
| echo "should_release=${should_release}" >> "$GITHUB_OUTPUT" | |
| - name: Require validated release candidate | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PACKAGE_VERSION: ${{ steps.release_guard.outputs.package_version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| commit_message="$(git log -1 --pretty=%B "${GITHUB_SHA}")" | |
| if [[ "$commit_message" == *"[skip-rc-check]"* ]]; then | |
| echo "Commit message contains [skip-rc-check]; bypassing rc validation." | |
| exit 0 | |
| fi | |
| rc_tags="$( | |
| gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,isPrerelease \ | |
| --jq ".[] | select(.isPrerelease) | .tagName" \ | |
| | grep -E "^v${PACKAGE_VERSION}-rc\.[0-9]+$" || true | |
| )" | |
| if [[ -z "$rc_tags" ]]; then | |
| echo "No release candidate found for v${PACKAGE_VERSION}. Publish an rc via the staging branch first, or add [skip-rc-check] to the commit message to bypass." >&2 | |
| exit 1 | |
| fi | |
| validated=false | |
| while IFS= read -r rc_tag; do | |
| [[ -z "$rc_tag" ]] && continue | |
| rc_ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${rc_tag}")" | |
| rc_sha="$(jq -r '.object.sha' <<< "$rc_ref_json")" | |
| rc_type="$(jq -r '.object.type' <<< "$rc_ref_json")" | |
| # Annotated tags need a second lookup to get the commit SHA. | |
| # Lightweight tags already point directly at the commit. | |
| if [[ "$rc_type" == "tag" ]]; then | |
| rc_commit_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${rc_sha}" --jq '.object.sha')" | |
| else | |
| rc_commit_sha="$rc_sha" | |
| fi | |
| # Ask GitHub whether this rc commit is an ancestor of GITHUB_SHA. | |
| # behind_by == 0 means GITHUB_SHA contains rc_commit_sha in its history. | |
| # We query the API instead of `git merge-base --is-ancestor` because | |
| # actions/checkout doesn't always make every tagged commit locally | |
| # reachable, even with fetch-depth: 0. | |
| behind_by="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${rc_commit_sha}...${GITHUB_SHA}" --jq '.behind_by')" | |
| echo "Checking ${rc_tag} (${rc_commit_sha}): behind_by=${behind_by}" | |
| if [[ "$behind_by" == "0" ]]; then | |
| echo "Validated: ${rc_tag} (${rc_commit_sha}) is an ancestor of ${GITHUB_SHA}." | |
| validated=true | |
| break | |
| fi | |
| done <<< "$rc_tags" | |
| if [[ "$validated" != "true" ]]; then | |
| echo "Found rc tags for v${PACKAGE_VERSION} but none are ancestors of ${GITHUB_SHA}. Promote the tested commit, or add [skip-rc-check] to bypass." >&2 | |
| exit 1 | |
| fi | |
| - name: Validate release configuration | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| required=( | |
| HEADROOM_ACCOUNT_API_BASE_URL | |
| HEADROOM_APTABASE_APP_KEY | |
| HEADROOM_UPDATER_PUBLIC_KEY | |
| APPLE_CERTIFICATE | |
| APPLE_CERTIFICATE_PASSWORD | |
| APPLE_SIGNING_IDENTITY | |
| TAURI_SIGNING_PRIVATE_KEY | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD | |
| HEADROOM_SENTRY_DSN | |
| VITE_SENTRY_DSN | |
| ) | |
| for key in "${required[@]}"; do | |
| if [[ -z "${!key:-}" ]]; then | |
| echo "Missing required secret or variable: $key" >&2 | |
| exit 1 | |
| fi | |
| done | |
| if [[ -n "${APPLE_API_ISSUER:-}" && -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_PRIVATE_KEY_P8:-}" ]]; then | |
| exit 0 | |
| fi | |
| if [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then | |
| exit 0 | |
| fi | |
| echo "Configure either App Store Connect notarization secrets or Apple ID notarization secrets." >&2 | |
| exit 1 | |
| - name: Debug secrets presence | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| shell: bash | |
| run: | | |
| echo "APPLE_API_PRIVATE_KEY_P8 set: $([[ -n "${APPLE_API_PRIVATE_KEY_P8:-}" ]] && echo YES || echo NO)" | |
| echo "APPLE_API_KEY set: $([[ -n "${APPLE_API_KEY:-}" ]] && echo YES || echo NO)" | |
| echo "APPLE_API_ISSUER set: $([[ -n "${APPLE_API_ISSUER:-}" ]] && echo YES || echo NO)" | |
| - name: Prepare App Store Connect API key | |
| if: steps.release_guard.outputs.should_release == 'true' && env.APPLE_API_PRIVATE_KEY_P8 != '' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" | |
| printf '%s' "$APPLE_API_PRIVATE_KEY_P8" > "$key_path" | |
| echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" | |
| - name: Install frontend dependencies | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| run: npm ci | |
| - name: Run release checks | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| timeout-minutes: 12 | |
| run: ./scripts/verify-release.sh | |
| - name: Load release notes | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| id: release_notes | |
| env: | |
| PACKAGE_VERSION: ${{ steps.release_guard.outputs.package_version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| notes_file=".github/release-notes/${PACKAGE_VERSION}.md" | |
| # Body drives both the GitHub release page and latest.json `notes`, | |
| # which the in-app update dialog renders. When the per-version file | |
| # is missing, ship an empty body so the dialog skips the notes block. | |
| { | |
| echo "body<<RELEASE_NOTES_EOF" | |
| if [[ -f "$notes_file" ]]; then | |
| cat "$notes_file" | |
| fi | |
| echo "RELEASE_NOTES_EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Build and publish Tauri release | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| uses: tauri-apps/tauri-action@action-v0.6.2 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| HEADROOM_UPDATER_ENDPOINTS: ${{ env.HEADROOM_UPDATER_ENDPOINTS }} | |
| HEADROOM_UPDATER_STAGING_ENDPOINTS: ${{ env.HEADROOM_UPDATER_STAGING_ENDPOINTS }} | |
| HEADROOM_UPDATER_PUBLIC_KEY: ${{ env.HEADROOM_UPDATER_PUBLIC_KEY }} | |
| APPLE_CERTIFICATE: ${{ env.APPLE_CERTIFICATE }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ env.APPLE_CERTIFICATE_PASSWORD }} | |
| APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} | |
| APPLE_API_ISSUER: ${{ env.APPLE_API_ISSUER }} | |
| APPLE_API_KEY: ${{ env.APPLE_API_KEY }} | |
| APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} | |
| APPLE_TEAM_ID: ${{ env.APPLE_TEAM_ID }} | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ env.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| HEADROOM_SENTRY_DSN: ${{ env.HEADROOM_SENTRY_DSN }} | |
| VITE_SENTRY_DSN: ${{ env.VITE_SENTRY_DSN }} | |
| with: | |
| projectPath: . | |
| tagName: v__VERSION__ | |
| releaseName: Headroom v__VERSION__ | |
| releaseBody: ${{ steps.release_notes.outputs.body }} | |
| generateReleaseNotes: false | |
| releaseDraft: false | |
| prerelease: false | |
| assetNamePattern: '[name]_[version]_mac[ext]' | |
| includeUpdaterJson: true | |
| args: --target aarch64-apple-darwin | |
| - name: Mirror stable artifacts to rolling staging release | |
| if: steps.release_guard.outputs.should_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| STABLE_TAG: v${{ steps.release_guard.outputs.package_version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| workdir="$(mktemp -d)" | |
| gh release download "$STABLE_TAG" --repo "$GITHUB_REPOSITORY" --dir "$workdir" | |
| gh release delete staging-rolling --cleanup-tag --yes --repo "$GITHUB_REPOSITORY" || true | |
| gh release create staging-rolling \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --title "Headroom staging (rolling)" \ | |
| --notes "Rolling pointer to the latest build. Current: ${STABLE_TAG} (stable)." \ | |
| --prerelease \ | |
| --target "$GITHUB_SHA" \ | |
| "$workdir"/* |