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
53 changes: 53 additions & 0 deletions .github/actions/apple-credentials/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Apple credentials
description: >-
Decide whether this run can sign for macOS and, when it can, put the App Store
Connect .p8 on disk so notarytool and tauri-action can find it.

inputs:
certificate:
description: Base64 of the Developer ID Application .p12 (secrets.APPLE_CERTIFICATE)
required: false
default: ""
api-key-p8:
description: Base64 of the App Store Connect AuthKey .p8 (secrets.APPLE_API_KEY_P8)
required: false
default: ""
required:
description: >-
"true" on a tagged release, where missing credentials are a hard failure
rather than a reason to fall back to an unsigned build.
required: false
default: "false"

outputs:
sign:
description: '"true" when both credentials are present and signing should run'
value: ${{ steps.check.outputs.sign }}

runs:
using: composite
steps:
- id: check
shell: bash
env:
CERTIFICATE: ${{ inputs.certificate }}
API_KEY_P8: ${{ inputs.api-key-p8 }}
REQUIRED: ${{ inputs.required }}
run: |
set -euo pipefail
if [ -z "$CERTIFICATE" ] || [ -z "$API_KEY_P8" ]; then
if [ "$REQUIRED" = "true" ]; then
echo "::error::A tagged release must ship signed macOS artifacts, but APPLE_CERTIFICATE and/or APPLE_API_KEY_P8 are missing. See docs/macos-signing.md."
exit 1
fi
echo "sign=false" >> "$GITHUB_OUTPUT"
echo "No Apple credentials — this run produces unsigned macOS artifacts."
exit 0
Comment on lines +38 to +45
fi
key_path="$RUNNER_TEMP/AuthKey.p8"
umask 077
printf '%s' "$API_KEY_P8" | base64 --decode > "$key_path"
# tauri-action reads APPLE_API_KEY_PATH from the environment, and
# scripts/macos-notarize.sh passes it to notarytool as --key.
echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV"
echo "sign=true" >> "$GITHUB_OUTPUT"
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ jobs:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
# src-tauri declares the CLI as an externalBin, and its build script fails
# while that file is missing — which `cargo test --workspace` hits, because
# src-tauri is in this workspace. dev, not release-cli: the test run reuses
# the debug artifacts instead of paying for a second full compile.
- run: bash scripts/build-sidecar.sh x86_64-unknown-linux-gnu dev
- run: cargo test --workspace
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo build -p mandalo-cli
Expand Down Expand Up @@ -84,7 +89,8 @@ jobs:
run: |
sudo apt-get update && sudo apt-get install -y shellcheck
shellcheck -s sh scripts/install.sh
shellcheck scripts/package-cli.sh scripts/release-notes.sh scripts/sync-packaging.sh
shellcheck scripts/package-cli.sh scripts/release-notes.sh scripts/sync-packaging.sh \
scripts/macos-sign.sh scripts/macos-notarize.sh scripts/build-sidecar.sh
- name: The installer the site serves is the installer in the repo
run: |
diff -u scripts/install.sh landing/install.sh ||
Expand Down
99 changes: 98 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,32 @@ jobs:
- label: macOS (Apple silicon)
platform: macos-14
args: --target aarch64-apple-darwin
sidecar: aarch64-apple-darwin
- label: macOS (Intel)
platform: macos-14
args: --target x86_64-apple-darwin
sidecar: x86_64-apple-darwin
- label: macOS (universal)
platform: macos-14
args: --target universal-apple-darwin
sidecar: universal-apple-darwin
- label: Linux
platform: ubuntu-22.04
args: ""
sidecar: x86_64-unknown-linux-gnu
- label: Windows
platform: windows-latest
args: ""
sidecar: x86_64-pc-windows-msvc
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install Tauri system deps (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
pkg-config libssl-dev cmake
- uses: pnpm/action-setup@v4
with:
version: 10
Expand All @@ -56,18 +62,66 @@ jobs:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
- run: pnpm install --frozen-lockfile
# The bundle carries the CLI as a Tauri sidecar, which is what lets the
# Homebrew cask link one `mandalo` out of the installed .app. Without this
# step `tauri build` fails: externalBin is declared in tauri.conf.json.
- name: Stage the CLI sidecar
shell: bash
run: bash scripts/build-sidecar.sh ${{ matrix.sidecar }}
- name: Apple credentials
id: apple
if: runner.os == 'macOS'
uses: ./.github/actions/apple-credentials
with:
certificate: ${{ secrets.APPLE_CERTIFICATE }}
api-key-p8: ${{ secrets.APPLE_API_KEY_P8 }}
required: ${{ startsWith(github.ref, 'refs/tags/v') }}
# tauri-action signs the .app and .dmg from APPLE_CERTIFICATE and notarizes
# the .app from the APPLE_API_* trio; with none of them set it just builds
# unsigned. It does NOT notarize the .dmg — see the step after it.
- uses: tauri-apps/tauri-action@v0
if: startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
with:
tagName: ${{ github.ref_name }}
releaseName: "Mándalo ${{ github.ref_name }}"
releaseDraft: true
updaterJsonPreferNsis: true
args: ${{ matrix.args }}
# Tauri notarizes and staples the .app but leaves the .dmg carrying it
# merely signed, and Gatekeeper assesses the downloaded .dmg on its own:
# `spctl -a -t open` on it reports "Unnotarized Developer ID" and refuses
# to open it, however notarized the app inside is. So notarize the disk
# image too and put the stapled copy back on the release, before the
# publish job hashes what is there into SHA256SUMS.
- name: Notarize and staple the DMG
if: steps.apple.outputs.sign == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
dmgs=(target/*/release/bundle/dmg/*.dmg)
if [ ${#dmgs[@]} -eq 0 ]; then
echo "::error::tauri-action produced no .dmg for ${{ matrix.label }}"
exit 1
fi
for dmg in "${dmgs[@]}"; do
bash scripts/macos-notarize.sh "$dmg"
spctl -a -t open --context context:primary-signature -v "$dmg"
gh release upload "${{ github.ref_name }}" "$dmg" --clobber
done
# workflow_dispatch has no tag to release into, so it only proves it builds.
- uses: tauri-apps/tauri-action@v0
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
Expand Down Expand Up @@ -108,6 +162,28 @@ jobs:
key: ${{ matrix.target }}
- name: Build
run: cargo build --profile release-cli -p mandalo-cli --target ${{ matrix.target }}
- name: Apple credentials
id: apple
if: runner.os == 'macOS'
uses: ./.github/actions/apple-credentials
with:
certificate: ${{ secrets.APPLE_CERTIFICATE }}
api-key-p8: ${{ secrets.APPLE_API_KEY_P8 }}
required: ${{ startsWith(github.ref, 'refs/tags/v') }}
# Signing has to happen before packaging: the tar.gz must carry the signed
# binary, and notarytool cannot read a tar.gz anyway.
- name: Sign and notarize the binary (macOS)
if: steps.apple.outputs.sign == 'true'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
binary=target/${{ matrix.target }}/release-cli/mandalo
bash scripts/macos-sign.sh "$binary"
bash scripts/macos-notarize.sh "$binary"
- name: Package
id: pkg
shell: bash
Expand Down Expand Up @@ -178,6 +254,27 @@ jobs:
- name: Fetch or build the CLI
working-directory: editor-extension
run: node scripts/fetch-cli.mjs --target ${{ matrix.target }} --build
- name: Apple credentials
id: apple
if: startsWith(matrix.target, 'darwin-')
uses: ./.github/actions/apple-credentials
with:
certificate: ${{ secrets.APPLE_CERTIFICATE }}
api-key-p8: ${{ secrets.APPLE_API_KEY_P8 }}
required: ${{ startsWith(github.ref, 'refs/tags/v') }}
# The binary inside the VSIX is the one that actually runs on the user's
# machine, so it needs the same Developer ID signature as the standalone CLI.
- name: Sign and notarize the bundled CLI (macOS)
if: steps.apple.outputs.sign == 'true'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
bash scripts/macos-sign.sh editor-extension/bin/mandalo
bash scripts/macos-notarize.sh editor-extension/bin/mandalo
- name: The VSIX carries the binary it claims to
if: matrix.target != 'universal'
working-directory: editor-extension
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dist-ssr
# Rust workspace build output
/target/

# CLI sidecar staged into the desktop bundle (scripts/build-sidecar.sh)
/src-tauri/binaries/

# Built browser app + its generated script worker (produced by CI, see landing.yml)
/landing/app/
/src/lib/web/public/
Expand Down
101 changes: 101 additions & 0 deletions docs/macos-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# macOS signing and notarization

Unsigned builds on macOS open to *"Mándalo is damaged and can't be opened"*. The
release workflow fixes that by signing everything it ships with a **Developer ID
Application** certificate and notarizing it with Apple:

| Artifact | Signed by | Ticket stapled |
| --- | --- | --- |
| `Mandalo_*.dmg`, `mandalo.app` | `tauri-action` | yes |
| `mandalo-v*-*-apple-darwin.tar.gz` (CLI) | `scripts/macos-sign.sh` | no — bare Mach-O, Gatekeeper checks online |
| `mandalo-darwin-*.vsix` (bundled CLI) | `scripts/macos-sign.sh` | no, same reason |

Without the secrets below the workflow still builds, just unsigned — except on a
`v*` tag, where a missing credential fails the job instead of quietly shipping
something Gatekeeper will reject.

## Repository secrets

| Secret | What it is |
| --- | --- |
| `APPLE_CERTIFICATE` | base64 of the Developer ID Application `.p12` |
| `APPLE_CERTIFICATE_PASSWORD` | the password that `.p12` was exported with |
| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Dani Rus (TEAMID1234)` |
| `APPLE_API_KEY` | App Store Connect key id, e.g. `DHF22R7L7Y` |
| `APPLE_API_ISSUER` | App Store Connect issuer UUID |
| `APPLE_API_KEY_P8` | base64 of `AuthKey_<key id>.p8` |

## Getting the certificate

The certificate is per-team, not per-machine — do this once and keep the `.p12`
in a password manager. The private key never leaves your machine or the manager.

```bash
# 1. A key and a certificate signing request.
openssl genrsa -out developer-id.key 2048
openssl req -new -key developer-id.key -out developer-id.csr \
-subj "/emailAddress=you@example.com/CN=Your Name/C=RO"
```

Upload `developer-id.csr` at
<https://developer.apple.com/account/resources/certificates/add> → **Developer ID
Application** → *Previous Sub-CA* is not needed, take the default (G2) → download
`developerID_application.cer`.

```bash
# 2. Combine the downloaded certificate with the key into a .p12.
openssl x509 -inform DER -in developerID_application.cer -out developer-id.pem
openssl pkcs12 -export -legacy \
-inkey developer-id.key -in developer-id.pem \
-out developer-id.p12 -name "Developer ID Application"

# 3. The secret values.
base64 -i developer-id.p12 | pbcopy # -> APPLE_CERTIFICATE
openssl x509 -in developer-id.pem -noout -subject # -> APPLE_SIGNING_IDENTITY (the CN)
```

`-legacy` matters: without it OpenSSL 3 writes a `.p12` that macOS `security
import` refuses.

## Getting the API key

<https://appstoreconnect.apple.com/access/integrations/api> → **Keys** → generate
one with the **Developer** role. The page shows the **Issuer ID** once at the top
(`APPLE_API_ISSUER`), the key id in the row (`APPLE_API_KEY`), and lets you
download `AuthKey_<key id>.p8` exactly once.

```bash
base64 -i ~/Downloads/AuthKey_DHF22R7L7Y.p8 | pbcopy # -> APPLE_API_KEY_P8
```

## Uploading them

```bash
gh secret set APPLE_CERTIFICATE < <(base64 -i developer-id.p12)
gh secret set APPLE_CERTIFICATE_PASSWORD
gh secret set APPLE_SIGNING_IDENTITY
gh secret set APPLE_API_KEY
gh secret set APPLE_API_ISSUER
gh secret set APPLE_API_KEY_P8 < <(base64 -i AuthKey_DHF22R7L7Y.p8)
```

## Checking a release

```bash
spctl --assess --type execute --verbose=4 /Applications/mandalo.app
xcrun stapler validate /Applications/mandalo.app
```

For the CLI, `spctl --assess` answers `the code is valid but does not seem to be
an app` — it only assesses bundles, so that sentence is a pass, not a failure.
What actually proves a standalone binary is fine is that it runs with the
quarantine bit set:

```bash
codesign -dv --verbose=4 ./mandalo # expect Authority=…, flags=0x10000(runtime), Timestamp=…
xattr -w com.apple.quarantine "0083;00000000;Safari;" ./mandalo
./mandalo --version
```

A bare Mach-O cannot carry a stapled ticket, so that check needs the machine to
be online the first time. It is the best available for a standalone binary.
Loading
Loading