Skip to content
Open
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
152 changes: 152 additions & 0 deletions .github/workflows/release-macos.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ riderModule.iml

# Test output (TUnit/MTP reports and captured UI screenshots)
/artifacts/
TestResults/
TestResults/

# Parcel pack logs
/assets/*.log
88 changes: 88 additions & 0 deletions build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://account.apple.com> → 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 <https://developer.apple.com/account> → 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: <Name> (<TEAMID>)":
"$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
Expand Down
58 changes: 58 additions & 0 deletions src/Fido.parcel
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
Loading