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
76 changes: 76 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,47 @@ jobs:
echo "JAVA_HOME=$(pwd)/$JVM_NAME" >> $GITHUB_ENV
echo "$(pwd)/$JVM_NAME/bin" >> $GITHUB_PATH

- name: Import Developer ID signing certificate
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: |
if [ -z "$MACOS_CERT_P12" ]; then
echo "No MACOS_CERT_P12 secret set -> CI will build UNSIGNED."
exit 0
fi
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
KEYCHAIN_PW="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH"
echo "$MACOS_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PW" "$KEYCHAIN_PATH" >/dev/null
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
rm -f "$RUNNER_TEMP/cert.p12"
echo "MACOS_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"

- name: Prepare notarization API key
env:
NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }}
run: |
if [ -z "$NOTARY_KEY_P8" ]; then
echo "No NOTARY_KEY_P8 secret -> notarization falls back to Apple ID creds if present."
exit 0
fi
echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary_key.p8"
echo "NOTARY_KEY_PATH=$RUNNER_TEMP/notary_key.p8" >> "$GITHUB_ENV"

- name: Build
env:
VERSION_SEMVER: ${{ steps.get_version.outputs.VERSION }}
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }}
NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }}
NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }}
run: bash package-macos.sh

- name: Upload result for job Mac-arm
Expand Down Expand Up @@ -277,9 +315,47 @@ jobs:
echo "JAVA_HOME=$(pwd)/$JVM_NAME" >> $GITHUB_ENV
echo "$(pwd)/$JVM_NAME/bin" >> $GITHUB_PATH

- name: Import Developer ID signing certificate
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: |
if [ -z "$MACOS_CERT_P12" ]; then
echo "No MACOS_CERT_P12 secret set -> CI will build UNSIGNED."
exit 0
fi
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
KEYCHAIN_PW="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH"
echo "$MACOS_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PW" "$KEYCHAIN_PATH" >/dev/null
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
rm -f "$RUNNER_TEMP/cert.p12"
echo "MACOS_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"

- name: Prepare notarization API key
env:
NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }}
run: |
if [ -z "$NOTARY_KEY_P8" ]; then
echo "No NOTARY_KEY_P8 secret -> notarization falls back to Apple ID creds if present."
exit 0
fi
echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary_key.p8"
echo "NOTARY_KEY_PATH=$RUNNER_TEMP/notary_key.p8" >> "$GITHUB_ENV"

- name: Build
env:
VERSION_SEMVER: ${{ steps.get_version.outputs.VERSION }}
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }}
NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }}
NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }}
run: bash package-macos.sh

- name: Upload result for job Mac-x86
Expand Down
138 changes: 138 additions & 0 deletions MACOS-SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# macOS Code Signing & Notarization

CaDoodle ships two macOS artifacts with different signing requirements:

| Artifact | Distribution | Signing | Status |
|---|---|---|---|
| `CaDoodle-MacOS-{arm64,x86_64}.dmg` | Direct download (GitHub Releases) | **Developer ID Application** + **notarization** + staple | Wired up (this doc) |
| Mac App Store build | App Store | Apple Distribution + sandbox + provisioning profile | **Not yet implemented** — see [Mac App Store](#mac-app-store-pending) |

This doc covers the Developer ID / notarization path used by [`package-macos.sh`](package-macos.sh). That is what stops macOS (including macOS 26) from blocking the downloaded DMG with *"Apple could not verify this app is free of malware."*

## How signing is wired

`package-macos.sh` is driven entirely by environment variables, so the same script runs on a dev machine and in CI:

- **No variables set** → the DMG is built **unsigned** (local dev builds keep working). macOS warns on such a build when downloaded.
- `MACOS_SIGN_IDENTITY` set → `jpackage` signs the app with the hardened runtime + [`mac-entitlements.plist`](mac-entitlements.plist).
- Notarization credentials also set → the DMG is submitted to Apple's notary service, stapled, and validated.

### Environment variables

**Signing**

| Var | Required | Meaning |
|---|---|---|
| `MACOS_SIGN_IDENTITY` | for signing | Name portion of the identity, e.g. `Common Wealth Robotics (TEAMID)`. `jpackage` resolves `Developer ID Application: <this>` in the keychain. |
| `MACOS_KEYCHAIN` | optional | Path to the keychain holding the cert. Omit to use the login keychain (CI sets a temporary keychain). |

**Notarization** — provide **one** of these sets (API key preferred):

| Vars | Set | Meaning |
|---|---|---|
| `NOTARY_KEY_PATH`, `NOTARY_KEY_ID`, `NOTARY_ISSUER_ID` | A | App Store Connect API key (`.p8`), its key ID, and the issuer UUID |
| `NOTARY_APPLE_ID`, `NOTARY_TEAM_ID`, `NOTARY_PASSWORD` | B | Apple ID, 10-char Team ID, app-specific password |

## Prerequisites

- An **Apple Developer Program** membership. Your 10-char **Team ID** is on the Membership page.
- Xcode (provides `notarytool`, `stapler`, and the easy cert-creation flow).

## 1. Create the Developer ID Application certificate

**Xcode (easiest):** Xcode → Settings → Accounts → sign in → select the team → *Manage Certificates…* → **+** → **Developer ID Application**. The certificate and its private key land in your login keychain.

> Developer ID certificates can only be created by the team's **Account Holder**. If the **+** menu doesn't offer it, use the portal flow below from the Account Holder's account.

**Portal:** create a CSR (Keychain Access → Certificate Assistant → *Request a Certificate From a CA…*, "Saved to disk"), upload it at developer.apple.com → Certificates → **+** → *Developer ID Application*, download the `.cer`, and double-click to install.

Confirm:
```bash
security find-identity -v -p codesigning
# 1) ABC… "Developer ID Application: Common Wealth Robotics (TEAMID)"
```

## 2. Create the notarization credential

App Store Connect → Users and Access → Integrations → **Keys** → generate a key with **Developer** access. Download `AuthKey_XXXX.p8` (one-time download). Note the **Key ID** (beside the key) and the **Issuer ID** (top of the page).

## 3. Build & verify locally

```bash
export MACOS_SIGN_IDENTITY="Common Wealth Robotics (TEAMID)"
export NOTARY_KEY_PATH="$HOME/keys/AuthKey_XXXX.p8"
export NOTARY_KEY_ID="XXXXXXXXXX"
export NOTARY_ISSUER_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

bash package-macos.sh
```

Verify the result:
```bash
xcrun stapler validate release/CaDoodle-MacOS-arm64.dmg
spctl -a -t open --context context:primary-signature -v release/CaDoodle-MacOS-arm64.dmg
# → accepted source=Notarized Developer ID
```

## 4. CI (GitHub Actions)

**You do not edit the workflow.** [`.github/workflows/release.yml`](.github/workflows/release.yml) already imports the certificate into a throwaway keychain, writes the notary key to a temp file, and runs `package-macos.sh` in both the `macos-arm` and `macos` jobs. The only thing to add in GitHub is the secrets below.

Add each at **Settings → Secrets and variables → Actions → New repository secret**
(`https://github.com/CommonWealthRobotics/CaDoodle/settings/secrets/actions`):

| Secret | Value / how to produce it |
|---|---|
| `MACOS_SIGN_IDENTITY` | The identity's name portion, e.g. `Your Name (TEAMID)` — copy it verbatim from `security find-identity -v -p codesigning` |
| `MACOS_CERT_PASSWORD` | The password you choose when exporting the `.p12` (below) |
| `MACOS_CERT_P12` | base64 of the exported identity (below) |
| `NOTARY_KEY_ID` | The key's 10-char Key ID |
| `NOTARY_ISSUER_ID` | The Issuer UUID from the Keys page |
| `NOTARY_KEY_P8` | base64 of your `AuthKey_XXXX.p8` (below) |

**Export the signing identity → `.p12` → base64.** In Keychain Access, expand the **Developer ID Application** identity, select the certificate **and** its private key, right-click → **Export 2 items…** → save as `.p12` and set a password (that password becomes `MACOS_CERT_PASSWORD`). Then:

```bash
base64 -i devid.p12 | pbcopy # paste into MACOS_CERT_P12
```

**base64 the notary key:**

```bash
base64 -i ~/keys/AuthKey_XXXX.p8 | pbcopy # paste into NOTARY_KEY_P8
```

Secret **values live only in GitHub Secrets — never commit them** to this (public) repo.

The workflow runs on every push, so once these six secrets exist, the next push to any branch builds signed + notarized DMGs as artifacts (publishing is still gated on a tag). If the signing secrets are absent the jobs log a notice and build an **unsigned** DMG instead of failing — so forks/PRs without secrets still work; if you push before adding them, add the secrets and **Re-run all jobs** on that run.

> Alternative to the `NOTARY_KEY_*` trio: set `NOTARY_APPLE_ID` / `NOTARY_TEAM_ID` / `NOTARY_PASSWORD` secrets instead; the script accepts either form.

## Entitlements

[`mac-entitlements.plist`](mac-entitlements.plist) grants the hardened runtime what a JVM needs:

- `com.apple.security.cs.allow-jit` and `…allow-unsigned-executable-memory` — the JIT writes and executes generated code.
- `com.apple.security.cs.disable-library-validation` — the bundled JavaFX/runtime native libraries (and the separately launched application JVM) are not all signed by this Team.
- `com.apple.security.cs.allow-dyld-environment-variables` — the launcher passes `JAVA_HOME` and other env through to the child process.

These suit a notarized Developer ID build; the App Store build needs a different, sandboxed set.

## Troubleshooting

- **codesign prompts for keychain access (local):** click *Always Allow* once, or run
`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> ~/Library/Keychains/login.keychain-db`.
- **Notarization rejected:** pull the detailed log with
`xcrun notarytool log <submission-id> --key … --key-id … --issuer …`. The usual cause is an unsigned nested binary (hardened runtime not applied) — confirm `--mac-sign` actually ran.
- **"The binary is not signed with a valid Developer ID certificate":** the name in `MACOS_SIGN_IDENTITY` doesn't match `security find-identity` output, or the cert isn't in the searched keychain (set `MACOS_KEYCHAIN`).

## Mac App Store (pending)

The App Store target is **not yet built**. It cannot reuse the DMG binary, because the App Store sandbox prohibits what the updater does at runtime: download a JVM + `CaDoodle-Application.jar`, execute them, and write under `~/bin`. A compliant build must:

1. Bundle the full JVM + `CaDoodle-Application.jar` at **build time** — no runtime download, no self-updater.
2. Enable `com.apple.security.app-sandbox` plus the file-access / network entitlements the app actually uses.
3. Sign with **Apple Distribution**, embed a provisioning profile, and wrap in a `.pkg` signed with the Mac installer identity.
4. Upload to App Store Connect via Transporter (notarization does not apply to App Store builds).

This is planned as a separate `package-macos-appstore.sh`.
75 changes: 75 additions & 0 deletions docs/macos-signing-and-notarization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# macOS signing & notarization — work record (2026-06-20)

A description of the change that added Developer ID signing + notarization to the
macOS build, and the scoping of the Mac App Store target. For *how to set it up*
(certificates, secrets, local/CI usage), see [`MACOS-SIGNING.md`](../MACOS-SIGNING.md).

## Problem

`package-macos.sh` produced an **unsigned, un-notarized** DMG. On macOS 26,
Gatekeeper blocks such a download with *"Apple could not verify this app is free
of malware,"* with no easy user override. A second goal was to prepare the app
for the **Mac App Store**.

## Architecture context (why the two goals diverge)

This repo (`Cadoodle`) is an **installer / auto-updater**, not the application.
At runtime [`CadoodleUpdater.java`](../CaDoodleUpdater/src/main/java/com/commonwealthrobotics/CadoodleUpdater.java)
and [`JvmManager.java`](../CaDoodleUpdater/src/main/java/com/commonwealthrobotics/JvmManager.java)
download a JVM and `CaDoodle-Application.jar` from the `CaDoodle-Application`
releases, write them under `~/bin/CaDoodle-ApplicationInstall/`, and execute
them.

- **Direct download (Developer ID):** notarizing the updater DMG is sufficient.
Files the updater downloads later are not browser-quarantined, so they run.
- **Mac App Store:** the sandbox **prohibits** downloading/executing code and
writing outside the container, and bans self-updaters. The updater model is
therefore incompatible; the App Store needs a *self-contained, sandboxed*
build instead (see [Pending](#pending)).

## What was implemented (Developer ID path)

| File | Change |
|---|---|
| [`mac-entitlements.plist`](../mac-entitlements.plist) | New. Hardened-runtime entitlements for a JVM: `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`, `allow-dyld-environment-variables`. |
| [`package-macos.sh`](../package-macos.sh) | Adds `--mac-sign` + entitlements to `jpackage`, then `notarytool submit --wait` → `stapler staple` → `stapler validate`. |
| [`.github/workflows/release.yml`](../.github/workflows/release.yml) | Both mac jobs import the Developer ID cert into a temporary keychain, materialize the notary API key, and pass credentials to the build. |

**Credential-driven, no-op without creds.** Everything reads from environment
variables, so the script runs unchanged locally and in CI:

- nothing set → **unsigned** DMG (prior behavior; dev builds keep working);
- `MACOS_SIGN_IDENTITY` set → signed with hardened runtime;
- notary creds also set → notarized + stapled.

Variables: `MACOS_SIGN_IDENTITY`, optional `MACOS_KEYCHAIN`; notarization via an
App Store Connect API key (`NOTARY_KEY_PATH` / `NOTARY_KEY_ID` /
`NOTARY_ISSUER_ID`) or Apple ID (`NOTARY_APPLE_ID` / `NOTARY_TEAM_ID` /
`NOTARY_PASSWORD`).

## Decisions & rationale

- **Developer ID + notarization (not just signing).** Signing alone does not
clear Gatekeeper on macOS 26; the DMG must be notarized and stapled.
- **Kept `jpackage --type dmg`** rather than refactoring to a manual
app-image → codesign → dmg pipeline. The `.app` inside is signed with the
hardened runtime and the DMG is stapled, which satisfies the download case.
Revisit only if a copied-out `.app` needs its own offline-stapled ticket.
- **App Store as a separate self-contained build** (chosen over putting MAS
packaging in the `CaDoodle-Application` repo). Bundles the full JVM +
application jar at build time, enables the sandbox, drops the updater.

## Verification gates

- `bash -n package-macos.sh` — passes.
- `release.yml` — valid YAML; both `macos` and `macos-arm` jobs carry the new steps.
- End-to-end signing/notarization **not yet exercised** — no signing identity is
installed on the build machine (`security find-identity -v -p codesigning`
returns 0). Activation prerequisites are in
[`MACOS-SIGNING.md`](../MACOS-SIGNING.md).

## Pending

- A self-contained, sandboxed Mac App Store build, planned as
`package-macos-appstore.sh` (Apple Distribution signing, provisioning profile,
`.pkg` via Transporter).
21 changes: 21 additions & 0 deletions mac-entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Entitlements for the notarized Developer ID build (hardened runtime).
A JVM JITs code into executable memory and loads its own bundled native
libraries, so the hardened runtime needs these relaxations or the runtime
crashes / notarization is refused. This is NOT the App Store sandbox set;
the self-contained App Store build uses a separate, sandboxed plist.
-->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Loading
Loading