Skip to content

0.4.2

0.4.2 #101

Workflow file for this run

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"/*