diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..833845b --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,152 @@ +name: Release (macOS) + +# Builds, signs and notarizes the macOS .dmg with Avalonia Parcel. +# +# Native AOT cannot cross-compile, so this MUST run on a macOS runner. +# Signing/notarization need an Apple Developer ID; the Parcel CLI needs an +# Avalonia Plus licence. All credentials come from repository secrets — see +# build.md ("Releasing the macOS build") for how to create each one. + +on: + push: + tags: [ 'v*' ] + workflow_dispatch: + inputs: + version: + description: 'Version to stamp into the build (e.g. 1.2.3)' + required: true + default: '0.1.0' + +jobs: + package: + name: Package macOS (osx-arm64) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install Avalonia Parcel + run: dotnet tool install --global AvaloniaUI.Parcel + + - name: Resolve version + id: version + # Tag pushes give refs/tags/vX.Y.Z -> strip the leading "v". + # Manual runs use the workflow_dispatch input. + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + fi + + - name: Decode and re-chain Developer ID certificate + env: + P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + run: | + set -euo pipefail + + # Parcel signs via `rcodesign`, which embeds ONLY the certificates found in + # the .p12 — unlike Apple's `codesign`, it never pulls the intermediate from a + # keychain. A .p12 exported from Keychain Access usually omits the "Developer + # ID Certification Authority" intermediate, so the signature can't be chained + # to the Apple root and notarization rejects every binary with "not signed + # with a valid Developer ID certificate". We rebuild the .p12 with the full + # Apple chain bundled in so the signature always validates. + + work="$RUNNER_TEMP/p12work" + mkdir -p "$work" + echo "$P12_BASE64" | base64 --decode > "$work/original.p12" + + # macOS runners expose LibreSSL as `openssl` (no -legacy support); use the + # Homebrew openssl@3, installing it only if the image lacks it. + ossl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)" + if [ -z "$ossl_prefix" ] || [ ! -x "$ossl_prefix/bin/openssl" ]; then + brew install openssl@3 + ossl_prefix="$(brew --prefix openssl@3)" + fi + OSSL="$ossl_prefix/bin/openssl" + "$OSSL" version + + # Apple's chain certs (DER). A Developer ID Application leaf chains: + # leaf -> "Developer ID Certification Authority" -> "Apple Root CA". + # Both intermediate generations (G2 current, G1 for older leaves) are bundled + # so the matching one is always present; the shared root completes the chain. + for f in DeveloperIDG2CA DeveloperIDCA; do + curl -fsSL -o "$work/$f.cer" "https://www.apple.com/certificateauthority/$f.cer" + "$OSSL" x509 -inform DER -in "$work/$f.cer" -out "$work/$f.pem" + done + curl -fsSL -o "$work/AppleRootCA.cer" "https://www.apple.com/appleca/AppleIncRootCertificate.cer" + "$OSSL" x509 -inform DER -in "$work/AppleRootCA.cer" -out "$work/AppleRootCA.pem" + cat "$work"/DeveloperIDG2CA.pem "$work"/DeveloperIDCA.pem "$work"/AppleRootCA.pem > "$work/chain.pem" + + # Split the leaf key + cert out of the original .p12 (-legacy: Keychain uses + # legacy RC2/3DES encryption that openssl 3 won't read otherwise). + "$OSSL" pkcs12 -legacy -in "$work/original.p12" -passin env:P12_PASSWORD -nocerts -nodes -out "$work/leaf.key" + "$OSSL" pkcs12 -legacy -in "$work/original.p12" -passin env:P12_PASSWORD -clcerts -nokeys -out "$work/leaf.crt" + if ! grep -q 'PRIVATE KEY' "$work/leaf.key" || ! grep -q 'BEGIN CERTIFICATE' "$work/leaf.crt"; then + echo "::error::Could not extract the private key and leaf certificate from APPLE_DEVELOPER_ID_P12_BASE64 — check the secret and APPLE_DEVELOPER_ID_P12_PASSWORD." + exit 1 + fi + + # Fail fast on the wrong certificate TYPE: notarization only accepts a + # "Developer ID Application" leaf, and no CI step can fix a wrong-type cert. + # Surfacing it here avoids a slow, cryptic notary round-trip. + subject="$("$OSSL" x509 -in "$work/leaf.crt" -noout -subject)" + echo "Signing certificate subject: $subject" + case "$subject" in + *"Developer ID Application"*) echo "OK: Developer ID Application certificate." ;; + *) + echo "::error::The signing certificate is NOT a 'Developer ID Application' certificate (subject: $subject). Apple will not notarize it. Create a Developer ID Application certificate (requires a paid Apple Developer Program membership) and update APPLE_DEVELOPER_ID_P12_BASE64 — see build.md." + exit 1 ;; + esac + + # Re-pack with the full chain (-legacy keeps the RC2/3DES format rcodesign reads). + "$OSSL" pkcs12 -legacy -export \ + -inkey "$work/leaf.key" \ + -in "$work/leaf.crt" \ + -certfile "$work/chain.pem" \ + -passout env:P12_PASSWORD \ + -out "$RUNNER_TEMP/developer_id.p12" + + ncerts="$("$OSSL" pkcs12 -legacy -in "$RUNNER_TEMP/developer_id.p12" -passin env:P12_PASSWORD -nokeys 2>/dev/null | grep -c 'BEGIN CERTIFICATE' || true)" + echo "Rebuilt developer_id.p12 contains $ncerts certificates (leaf + chain)." + + rm -f "$work"/original.p12 "$work"/leaf.key "$work"/leaf.crt "$work"/chain.pem + + - name: Pack, sign and notarize + env: + # Avalonia Plus licence for the Parcel CLI. + PARCEL_LICENSE_KEY: ${{ secrets.PARCEL_LICENSE_KEY }} + # Version stamped into the bundle (Info.plist CFBundleShortVersionString). + FIDO_VERSION: ${{ steps.version.outputs.value }} + # Code signing — .parcel reads these env vars (see MacOsSettings). + APPLE_DEVELOPER_ID_P12: ${{ runner.temp }}/developer_id.p12 + APPLE_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + # Notarization. + APPLE_NOTARY_APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }} + APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + mkdir -p artifacts/macos + parcel pack src/Fido.parcel -r osx-arm64 -p dmg -o artifacts/macos + + - name: Remove decoded certificate + if: always() + run: rm -rf "$RUNNER_TEMP/developer_id.p12" "$RUNNER_TEMP/p12work" + + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: Fido-macos-arm64 + path: artifacts/macos/*.dmg + if-no-files-found: error + + - name: Attach DMG to GitHub release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: artifacts/macos/*.dmg diff --git a/.gitignore b/.gitignore index c2b1bdb..799cb19 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ riderModule.iml # Test output (TUnit/MTP reports and captured UI screenshots) /artifacts/ -TestResults/ \ No newline at end of file +TestResults/ + +# Parcel pack logs +/assets/*.log \ No newline at end of file diff --git a/build.md b/build.md index 87b3396..ac7fc08 100644 --- a/build.md +++ b/build.md @@ -58,6 +58,94 @@ For a portable build that relies on an installed .NET runtime: dotnet publish -c Release -p:PublishAot=false ``` +## Packaging the macOS app (signed + notarized) + +The macOS `.dmg` is built with [**Avalonia Parcel**](https://docs.avaloniaui.net/tools/parcel/) +from `src/Fido.parcel`. Two important constraints: + +- **Native AOT can't cross-compile**, so the Mac build must run *on a Mac* — we use the + `macos-latest` GitHub Actions runner (`.github/workflows/release-macos.yml`). +- For the app to open without Gatekeeper warnings it must be **signed with a Developer ID + Application certificate and notarized by Apple**. The signature carries the team name + (*Shine Forms*), which is what macOS shows users as the verified developer. + +### How it runs + +Push a tag (`git tag v1.2.3 && git push --tags`) or trigger the **Release (macOS)** +workflow manually. It installs Parcel, decodes the certificate, runs +`parcel pack src/Fido.parcel -r osx-arm64 -p dmg`, and attaches the `.dmg` to the release. +(Only `osx-arm64` is built today; add `-r osx-x64` to the workflow to also cover Intel Macs.) + +### Required GitHub secrets + +| Secret | What it is | How to get it | +| --- | --- | --- | +| `PARCEL_LICENSE_KEY` | Avalonia Plus licence for the Parcel **CLI** (separate from Apple). | From your Avalonia account portal. | +| `APPLE_DEVELOPER_ID_P12_BASE64` | Your *Developer ID Application* cert + private key, as a base64-encoded `.p12`. **Must be a "Developer ID Application" cert** — see below. | See below. | +| `APPLE_DEVELOPER_ID_P12_PASSWORD` | The password you set when exporting the `.p12`. | You choose it at export time. | +| `APPLE_NOTARY_APPLE_ID` | The Apple ID email of the Shine Forms developer account. | Your account login. | +| `APPLE_NOTARY_PASSWORD` | An **app-specific password** (not your Apple password). | Create at → Sign-In & Security → App-Specific Passwords. | +| `APPLE_TEAM_ID` | The 10-character Team ID. | Apple Developer site → Membership details. | + +The `.parcel` file references all of these by env-var name only — **no credentials are committed**. + +### Creating the Developer ID certificate (one-time, on a Mac) + +> ⚠️ It **must** be a **Developer ID Application** certificate. An *Apple Development*, +> *Apple Distribution*, *Mac Developer*, or *Developer ID Installer* cert will sign fine but +> Apple's notary rejects every binary with *"not signed with a valid Developer ID certificate."* +> Creating a Developer ID Application cert requires a **paid Apple Developer Program membership** +> (a free Apple ID can only make *Apple Development* certs). No membership? Use AdHoc (see below). + +1. In **Keychain Access** → Certificate Assistant → *Request a Certificate from a Certificate + Authority* — save the `.certSigningRequest` to disk. +2. At → Certificates → **+** → choose + **Developer ID Application**, upload the CSR, download the resulting `.cer`. +3. Double-click the `.cer` to import it; in Keychain Access find it, **expand it to include the + private key**, right-click → *Export* → save as `developer_id.p12` and set a password. + (You don't need to also select the intermediate — the workflow bundles Apple's full + certificate chain into the `.p12` automatically before signing.) +4. Base64-encode it for the GitHub secret: + - macOS/Linux: `base64 -i developer_id.p12 | pbcopy` + - Windows (PowerShell): `[Convert]::ToBase64String([IO.File]::ReadAllBytes("developer_id.p12")) | Set-Clipboard` + + Paste that into the `APPLE_DEVELOPER_ID_P12_BASE64` secret. + +### Notarization rejected? Check the certificate + +If notarization fails with *"The binary is not signed with a valid Developer ID certificate"*, +the signing identity in the secret is the problem (the rest of the pipeline is fine). The +workflow now bundles Apple's intermediate + root into the `.p12`, so a missing chain is handled +automatically — which leaves the **wrong cert type** as the cause. The workflow's *"Decode and +re-chain Developer ID certificate"* step prints the leaf subject and fails fast if it isn't a +Developer ID Application cert. To check the secret yourself on a Mac (Keychain exports need +Homebrew's openssl, as the system `openssl` is LibreSSL and lacks `-legacy`): + +```sh +OSSL="$(brew --prefix openssl@3)/bin/openssl" +# The CN must read "Developer ID Application: ()": +"$OSSL" pkcs12 -legacy -in developer_id.p12 -passin pass:YOUR_PASSWORD -clcerts -nokeys \ + | "$OSSL" x509 -noout -subject -issuer -dates +``` + +If the subject says anything else (*Apple Development*, *Apple Distribution*, *Developer ID +Installer*, …), regenerate it as a **Developer ID Application** cert per the steps above and +update both `APPLE_DEVELOPER_ID_P12_BASE64` and `APPLE_DEVELOPER_ID_P12_PASSWORD`. + +> No Apple Developer membership available? Set `MacOsSettings.SigningCredentialsType` back to +> `"AdHoc"` in `src/Fido.parcel`. The app still builds, but users must right-click → **Open** +> (or run `xattr -dr com.apple.quarantine /Applications/Fido.app`) the first time. + +### Building it locally on a Mac instead + +```sh +dotnet tool install --global AvaloniaUI.Parcel +export PARCEL_LICENSE_KEY=... FIDO_VERSION=1.2.3 +export APPLE_DEVELOPER_ID_P12=~/developer_id.p12 APPLE_DEVELOPER_ID_P12_PASSWORD=... +export APPLE_NOTARY_APPLE_ID=... APPLE_NOTARY_PASSWORD=... APPLE_TEAM_ID=... +parcel pack src/Fido.parcel -r osx-arm64 -p dmg -o artifacts/macos +``` + ## Notes - Settings persist to `%APPDATA%\Fido\config.json` (a legacy `atlantic-opener` folder is diff --git a/src/Fido.parcel b/src/Fido.parcel new file mode 100644 index 0000000..3035d54 --- /dev/null +++ b/src/Fido.parcel @@ -0,0 +1,58 @@ +{ + "GeneralSettings": { + "NetProjectPath": "Fido.csproj", + "ApplicationName": "Fido", + "Version": { + "$type": "env", + "name": "FIDO_VERSION" + }, + "PackageName": { + "$type": "msbuild", + "property": "AssemblyName" + }, + "AssemblyName": { + "$type": "msbuild", + "property": "AssemblyName" + } + }, + "LinuxSettings": { + "CreateBinSymlink": "True" + }, + "Win32Settings": { + "InstallerIcon": "../assets/fido.ico", + "Company": "Fido", + "InstallerRequiresAdmin": "False", + "IncludeUninstaller": "True" + }, + "MacOsSettings": { + "CreateBundle": true, + "BundleIdentifier": "com.shineforms.fido", + "TeamId": { + "$type": "env", + "name": "APPLE_TEAM_ID" + }, + "AppIcon": "../assets/png/fido-icon-1024.png", + "SigningCredentialsType": "P12Certificate", + "SigningP12Certificate": { + "$type": "env", + "name": "APPLE_DEVELOPER_ID_P12" + }, + "SigningP12Password": { + "$type": "env", + "name": "APPLE_DEVELOPER_ID_P12_PASSWORD" + }, + "NotaryCredentialsType": "AppleAccount", + "NotaryAppleId": { + "$type": "env", + "name": "APPLE_NOTARY_APPLE_ID" + }, + "NotaryAppPassword": { + "$type": "env", + "name": "APPLE_NOTARY_PASSWORD" + } + }, + "PublishSettings": { + "PublishSingleFile": "True", + "ExtraBuildProperties": {} + } +} \ No newline at end of file