Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions .github/workflows/release-macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
name: Release macOS

on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: Release tag to publish, for example v1.0.0-beta.1
required: true
type: string

permissions:
contents: write

concurrency:
group: release-macos-${{ github.ref_name || inputs.tag }}
cancel-in-progress: false

jobs:
release-macos:
name: Sign, notarize, and publish Apple Silicon DMG
runs-on: macos-15
environment: production
timeout-minutes: 60

env:
RELEASE_EVENT_NAME: ${{ github.event_name }}
RELEASE_INPUT_TAG: ${{ inputs.tag }}
RELEASE_REF_NAME: ${{ github.ref_name }}
APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }}
SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }}

steps:
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
release_tag="$RELEASE_REF_NAME"
if [[ "$RELEASE_EVENT_NAME" == 'workflow_dispatch' ]]; then
release_tag="$RELEASE_INPUT_TAG"
fi
if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
echo "Release tag must be a semantic version beginning with v. Received: $release_tag" >&2
exit 1
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"

- name: Checkout release tag
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}

- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json

- name: Set up Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: 1.97.1
targets: aarch64-apple-darwin

- name: Install dependencies
run: npm ci

- name: Verify release configuration
shell: bash
run: |
set -euo pipefail
node_version="$(node -p "require('./package.json').version")"
tauri_version="$(node -p "require('./src-tauri/tauri.conf.json').version")"
expected_tag="v${node_version}"
if [[ "$node_version" != "$tauri_version" ]]; then
echo "package.json version $node_version does not match Tauri version $tauri_version." >&2
exit 1
fi
if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then
echo "Release tag $RELEASE_TAG does not match app version $expected_tag." >&2
exit 1
fi
if [[ "$APPLE_SIGNING_IDENTITY" != 'Developer ID Application: '* ]]; then
echo 'APPLE_SIGNING_IDENTITY must name a Developer ID Application certificate.' >&2
exit 1
fi
if [[ "$APPLE_SIGNING_IDENTITY" != *"($APPLE_TEAM_ID)" ]]; then
echo 'APPLE_SIGNING_IDENTITY does not contain the configured APPLE_TEAM_ID.' >&2
exit 1
fi
for required_name in APPLE_API_ISSUER APPLE_API_KEY TIMBERLOGS_API_KEY SWITCHIFY_TELEMETRY_ENDPOINT; do
if [[ -z "${!required_name:-}" ]]; then
echo "$required_name is not configured for the production environment." >&2
exit 1
fi
done

- name: Install Developer ID certificate
shell: bash
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "$APPLE_CERTIFICATE_BASE64" || -z "$APPLE_CERTIFICATE_PASSWORD" ]]; then
echo 'The production Developer ID certificate is not configured.' >&2
exit 1
fi
certificate_path="$RUNNER_TEMP/switchify-developer-id.p12"
keychain_path="$RUNNER_TEMP/switchify-signing.keychain-db"
keychain_password="$(openssl rand -base64 32)"
echo "::add-mask::$keychain_password"
printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path"
chmod 600 "$certificate_path"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 -k "$keychain_path"
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$keychain_password" "$keychain_path"
security list-keychains -d user -s "$keychain_path"
identity_output="$(security find-identity -v -p codesigning "$keychain_path")"
if ! grep -Fq "\"$APPLE_SIGNING_IDENTITY\"" <<< "$identity_output"; then
echo "The configured Developer ID identity was not found in the imported certificate." >&2
exit 1
fi
echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"

- name: Install notarization API key
shell: bash
env:
APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }}
run: |
set -euo pipefail
if [[ -z "$APPLE_API_PRIVATE_KEY" ]]; then
echo 'APPLE_API_PRIVATE_KEY is not configured for the production environment.' >&2
exit 1
fi
api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
printf '%s' "$APPLE_API_PRIVATE_KEY" > "$api_key_path"
chmod 600 "$api_key_path"
echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV"

- name: Build signed and notarized app and signed DMG
run: npm run tauri build -- --bundles app,dmg --target aarch64-apple-darwin

- name: Notarize and staple DMG
shell: bash
run: |
set -euo pipefail
dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)"
if [[ -z "$dmg_path" ]]; then
echo 'No signed DMG was produced for notarization.' >&2
exit 1
fi
xcrun notarytool submit "$dmg_path" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait \
--timeout 20m
xcrun stapler staple "$dmg_path"

- name: Verify signed release
shell: bash
run: ./scripts/verify-macos-release.sh "$RELEASE_TAG" "$APPLE_SIGNING_IDENTITY" "$APPLE_TEAM_ID"

- name: Stage release assets
shell: bash
run: |
set -euo pipefail
mkdir -p dist
dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)"
if [[ -z "$dmg_path" ]]; then
echo 'No notarized DMG was produced.' >&2
exit 1
fi
cp "$dmg_path" dist/
asset_name="$(basename "$dmg_path")"
asset_hash="$(shasum -a 256 "dist/$asset_name" | awk '{print $1}')"
printf '%s %s\n' "$asset_hash" "$asset_name" > dist/SHA256SUMS-macos.txt

- name: Publish GitHub release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(dist/*)
if (( ${#assets[@]} == 0 )); then
echo 'No release assets found in dist.' >&2
exit 1
fi
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber
else
gh release create "$RELEASE_TAG" "${assets[@]}" \
--title "$RELEASE_TAG" \
--generate-notes \
--verify-tag
fi

- name: Upload workflow artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: switchify-pc-macos-${{ env.RELEASE_TAG }}
path: dist/*
if-no-files-found: error

- name: Remove temporary signing material
if: always()
shell: bash
run: |
if [[ -n "${APPLE_KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$APPLE_KEYCHAIN_PATH" 2>/dev/null || true
fi
rm -f \
"$RUNNER_TEMP/switchify-developer-id.p12" \
"$RUNNER_TEMP/AuthKey_${APPLE_API_KEY:-missing}.p8"
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ cargo test --manifest-path src-tauri/Cargo.toml

Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`.

## macOS releases

Production macOS releases are Apple Silicon DMGs signed with an Apple-issued Developer ID Application certificate, submitted to Apple's notarization service, and stapled for offline Gatekeeper verification. A `v*` tag or manual release run creates or updates the matching GitHub Release after verifying the tag, app version, architecture, nested-code signatures, hardened runtime, secure timestamp, and notarization tickets.

The production certificate and App Store Connect API key are held only in the GitHub `production` environment and imported into an ephemeral runner keychain. They are separate from the machine-local `Switchify PC Development` identity used by `npm run macos:run`. See [macOS production releases](docs/macos-releases.md) for certificate creation, GitHub configuration, release, recovery, and rotation instructions.

## Diagnostics

Switchify keeps up to 500 sanitized diagnostic events locally in `diagnostic-history.jsonl`. The history covers application startup, Bluetooth and Accessibility transitions, disconnects, runtime failures, and update checks. It never stores typed text, command payloads, pairing secrets, device names, or full paths; malformed or unwritable history is ignored so diagnostics cannot prevent startup.
Expand Down Expand Up @@ -86,7 +92,7 @@ Existing public Git history, tags, releases, update metadata, and installer down

## Development boundaries

- The macOS development identity is local-only. The application has no production Developer ID signing, notarization, or release publishing workflow; macOS CI builds with `--no-sign`. Windows production packages use the locally available Certum/SimplySign identity and are not published automatically.
- Pull-request and `main` macOS CI remains unsigned. Only authorized release tags and manual recovery runs can access the production Developer ID and notarization credentials. Windows production packages continue to use the locally available Certum/SimplySign identity and are not published automatically.
- Linux may appear in capability data but is not a supported Bluetooth target.
- Windows Grid 3 output uses the native `Sensory_SwitchInput` broadcast contract. Grid 3 is omitted from macOS capabilities and profiles.
- Update installation requires a signed Tauri update feed. Local development builds can only report updater configuration errors.
60 changes: 60 additions & 0 deletions docs/macos-releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# macOS production releases

Switchify PC is distributed outside the Mac App Store as an Apple Silicon DMG. Production releases use an Apple-issued **Developer ID Application** certificate and Apple notarization. The self-signed `Switchify PC Development` identity remains exclusively for local Accessibility testing through `npm run macos:run`.

## Create and preserve the certificate

1. In Keychain Access, create a certificate signing request using the Apple Developer account email. Select **Saved to disk** and **Let me specify key pair information**, then use RSA 2048-bit keys.
2. In Apple Developer Certificates, create a **Developer ID Application** certificate from that CSR. Download and install the certificate on the Mac that generated the CSR so it joins the existing private key.
3. Verify it appears under **My Certificates** and in `security find-identity -v -p codesigning` with its full `Developer ID Application: … (TEAMID)` identity.
4. Export the certificate and private key together as a password-protected `.p12`. Keep an encrypted offline backup of the `.p12` and its password. Never commit either one.
5. In App Store Connect → Users and Access → Integrations, create a team API key with **Developer** access. Download the `.p8` immediately; Apple only permits downloading it once.

Do not revoke or replace the certificate during normal renewal. Revocation invalidates future signing and can require an emergency rotation. Keep the local `Switchify PC Development`, earlier development, POC, and retired identities untouched.

## Configure GitHub

Create a `production` environment. Allow deployments from release tags matching `v*` and from `main` for manually dispatched recovery runs. No reviewer gate is required. Add a repository tag ruleset so only maintainers can create or update `v*` tags.

Configure these environment secrets:

| Name | Value |
| --- | --- |
| `APPLE_CERTIFICATE_BASE64` | Base64-encoded contents of the encrypted `.p12` |
| `APPLE_CERTIFICATE_PASSWORD` | Password used when exporting the `.p12` |
| `APPLE_API_ISSUER` | App Store Connect API issuer UUID |
| `APPLE_API_KEY` | App Store Connect API key ID |
| `APPLE_API_PRIVATE_KEY` | Complete contents of the downloaded `.p8` file |
| `TIMBERLOGS_API_KEY` | Production telemetry API key |

Configure these environment variables:

| Name | Value |
| --- | --- |
| `APPLE_SIGNING_IDENTITY` | Full `Developer ID Application: … (TEAMID)` identity |
| `APPLE_TEAM_ID` | Apple Developer team ID from the identity |
| `TIMBERLOGS_ENDPOINT` | Production HTTPS telemetry endpoint |

Encode the certificate without line wrapping on macOS:

```bash
base64 -i Switchify-PC-Developer-ID.p12 | tr -d '\n'
```

The workflow writes credentials only beneath the ephemeral runner directory, imports the certificate into a temporary keychain, masks its generated keychain password, and deletes the material during cleanup. Pull-request and `main` CI never receive production signing secrets.

## Publish a release

Keep `package.json` and `src-tauri/tauri.conf.json` versions identical, then create and push the corresponding tag, for example `v1.0.0-beta.1`. The release workflow checks out that exact tag, builds on an Apple Silicon runner, lets Tauri sign and notarize the app, then separately submits and staples the finished signed DMG. It validates Gatekeeper and both stapled tickets before creating or updating the matching GitHub Release.

The workflow can be manually dispatched with an existing tag to recover or replace a macOS asset. It never modifies earlier tags or release assets. The published DMG is accompanied by `SHA256SUMS-macos.txt`.

Updater feed generation and updater signing are intentionally separate work. Publishing a DMG does not make the in-app updater functional.

## Rotation and troubleshooting

- Track the Developer ID certificate expiry date and rotate it before expiry by issuing a new certificate, updating all certificate-related secrets together, and validating a release candidate before retiring the old certificate.
- If import fails, verify the `.p12` contains both certificate and private key and that its password is correct.
- If the identity check fails, copy the exact identity from `security find-identity -v -p codesigning`; it must start with `Developer ID Application:` and end with the configured team ID.
- If notarization fails, inspect the Tauri/notarytool output for unsigned nested code, missing hardened runtime, timestamp failures, or rejected entitlements. Do not bypass notarization or stapling.
- If Gatekeeper or stapling validation fails after Apple accepted the upload, rerun the workflow once before replacing credentials; Apple ticket availability can briefly lag.
Loading