From 857481c0dec96f5fdd5573ca1c290f9d6b722e3f Mon Sep 17 00:00:00 2001 From: seankearon Date: Tue, 23 Jun 2026 16:43:21 +0100 Subject: [PATCH 1/3] Set up signed, notarized macOS packaging Configure src/Fido.parcel and add a macOS release workflow so the app installs cleanly on macOS (no Gatekeeper warnings): - Icon: point AppIcon at the 1024px PNG (was a Windows .ico) so Parcel generates a proper .icns for the bundle. - Bundle identifier: com.shineforms.fido (was the placeholder). - Signing/notarization: switch from AdHoc to Developer ID + Apple notary, with all credentials referenced by env-var name only (nothing committed). - Fix Version wiring: it referenced PARCEL_LICENSE_KEY, which would have leaked the Avalonia licence key into the bundle Info.plist; now FIDO_VERSION. - Add .github/workflows/release-macos.yml: builds on macos-latest (Native AOT can't cross-compile), signs, notarizes, attaches the .dmg to the release. - Document the release process and required secrets in build.md. - Ignore stray Parcel pack logs (assets/*.log). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release-macos.yml | 82 +++++++++++++++++++++++++++++ .gitignore | 5 +- build.md | 59 +++++++++++++++++++++ src/Fido.parcel | 58 ++++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-macos.yml create mode 100644 src/Fido.parcel diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..f2070ee --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,82 @@ +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 Developer ID certificate + env: + P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + run: | + echo "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/developer_id.p12" + + - 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: | + parcel pack src/Fido.parcel -r osx-arm64 -p dmg -o artifacts/macos + + - name: Remove decoded certificate + if: always() + run: rm -f "$RUNNER_TEMP/developer_id.p12" + + - 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..f6669dd 100644 --- a/build.md +++ b/build.md @@ -58,6 +58,65 @@ 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`. | 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) + +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. +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. + +> 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 From b386f2675d2510c51c1367d2610d1de70f81409d Mon Sep 17 00:00:00 2001 From: seankearon Date: Wed, 24 Jun 2026 17:40:26 +0100 Subject: [PATCH 2/3] ci(macos): create output dir before parcel pack --- .github/workflows/release-macos.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index f2070ee..7734c88 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -62,6 +62,7 @@ jobs: 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 From 181fd35d364ea9e203580de3c9fb9f2fb9999acf Mon Sep 17 00:00:00 2001 From: seankearon Date: Wed, 24 Jun 2026 18:16:03 +0100 Subject: [PATCH 3/3] fix(macos): bundle Developer ID cert chain into p12 before signing Notarization rejected every binary with "not signed with a valid Developer ID certificate" even though rcodesign signed successfully. Parcel signs via rcodesign, which embeds only the certificates present in the .p12 (unlike Apple's codesign, it won't pull the intermediate from a keychain). A Keychain-exported .p12 typically omits the "Developer ID Certification Authority" intermediate, so the signature can't chain to the Apple root and notarization fails. Rebuild the decoded .p12 to include Apple's full chain (Developer ID G2/G1 intermediates + Apple Root CA) before handing it to Parcel, and fail fast with the cert subject if the leaf isn't a Developer ID Application cert (a wrong cert type can't be fixed in CI). Document the requirement and a local inspection command in build.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release-macos.yml | 75 +++++++++++++++++++++++++++-- build.md | 31 +++++++++++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 7734c88..833845b 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -42,11 +42,80 @@ jobs: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" fi - - name: Decode Developer ID certificate + - 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: | - echo "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/developer_id.p12" + 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: @@ -67,7 +136,7 @@ jobs: - name: Remove decoded certificate if: always() - run: rm -f "$RUNNER_TEMP/developer_id.p12" + run: rm -rf "$RUNNER_TEMP/developer_id.p12" "$RUNNER_TEMP/p12work" - name: Upload DMG artifact uses: actions/upload-artifact@v4 diff --git a/build.md b/build.md index f6669dd..ac7fc08 100644 --- a/build.md +++ b/build.md @@ -81,7 +81,7 @@ workflow manually. It installs Parcel, decodes the certificate, runs | 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`. | See below. | +| `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. | @@ -91,18 +91,47 @@ The `.parcel` file references all of these by env-var name only — **no credent ### 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.