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
101 changes: 100 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,53 @@ jobs:
shell: bash
run: ${SCCACHE_PATH} --show-stats

# Developer ID signing switches on once all five MACOS_* secrets
# exist (docs/macos-release-signing.md); until then the binary
# ships as the linker signed it and this prints a notice.
- name: Check macOS signing secrets
id: signing
if: (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && runner.os == 'macOS'
shell: bash
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: scripts/macos-release-signing.sh check

- name: Import Developer ID certificate
if: steps.signing.outputs.enabled == 'true'
shell: bash
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: scripts/macos-release-signing.sh import

# A bare executable cannot be stapled: it is signed and submitted,
# and Gatekeeper finds the ticket online.
- name: Sign and notarize macOS binary
if: steps.signing.outputs.enabled == 'true'
shell: bash
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: |
scripts/macos-release-signing.sh sign target/${{ matrix.target }}/release/rustcode
ditto -c -k --keepParent target/${{ matrix.target }}/release/rustcode "$RUNNER_TEMP/rustcode-cli-notarize.zip"
scripts/macos-release-signing.sh notarize "$RUNNER_TEMP/rustcode-cli-notarize.zip"

- name: Remove signing keychain
if: always() && steps.signing.outputs.enabled == 'true'
shell: bash
run: scripts/macos-release-signing.sh cleanup

- name: Package Linux / macOS binary
if: (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && runner.os != 'Windows'
shell: bash
Expand Down Expand Up @@ -148,12 +195,64 @@ jobs:
- name: Test Swift package
run: swift test --package-path macos

# Without the MACOS_* secrets the app stays ad-hoc signed and
# unnotarized (docs/macos-release-signing.md).
- name: Check macOS signing secrets
id: signing
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: scripts/macos-release-signing.sh check

- name: Import Developer ID certificate
if: steps.signing.outputs.enabled == 'true'
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: scripts/macos-release-signing.sh import

# Signs with MACOS_SIGN_IDENTITY when the import set it, else ad hoc.
- name: Package SwiftUI app
run: |
tar xzf engine/rustcode-macos-aarch64.tar.gz -C engine
chmod +x engine/rustcode-macos-aarch64
REQUIRE_COMPILED_ICON=1 scripts/package-swift-app.sh engine/rustcode-macos-aarch64 target/swift-release
ditto -c -k --sequesterRsrc --keepParent target/swift-release/RustCode.app rustcode-macos-aarch64-swift-app.zip

- name: Notarize and staple SwiftUI app
if: steps.signing.outputs.enabled == 'true'
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: scripts/macos-release-signing.sh notarize target/swift-release/RustCode.app

- name: Zip SwiftUI app
run: ditto -c -k --sequesterRsrc --keepParent target/swift-release/RustCode.app rustcode-macos-aarch64-swift-app.zip

# Check the asset as a user gets it: unpacked from the zip, with
# the quarantine flag a browser download sets.
- name: Verify downloaded app with Gatekeeper
if: steps.signing.outputs.enabled == 'true'
run: |
check="$RUNNER_TEMP/gatekeeper-check"
rm -rf "$check" && mkdir -p "$check"
ditto -x -k rustcode-macos-aarch64-swift-app.zip "$check"
xattr -w com.apple.quarantine "0083;$(printf '%x' "$(date +%s)");Safari;" "$check/RustCode.app"
xcrun stapler validate "$check/RustCode.app"
spctl --assess --type execute --verbose=4 "$check/RustCode.app"
codesign --verify --deep --strict --verbose=2 "$check/RustCode.app"

- name: Remove signing keychain
if: always() && steps.signing.outputs.enabled == 'true'
run: scripts/macos-release-signing.sh cleanup

- name: Upload app artifact
uses: actions/upload-artifact@v7
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/macos-swift-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
- .github/workflows/macos-swift-ci.yml
- macos/**
- scripts/package-swift-app.sh
- scripts/macos-release-signing.sh
workflow_dispatch:

concurrency:
Expand All @@ -31,3 +32,13 @@ jobs:

- name: Test
run: swift test --package-path macos

# Exercise the unsigned (ad-hoc) packaging path release builds
# take until the Developer ID secrets exist; any executable
# stands in for the engine.
- name: Package ad-hoc signed app
run: |
scripts/package-swift-app.sh /usr/bin/true target/swift-ci
codesign --verify --deep --strict --verbose=2 target/swift-ci/RustCode.app
bash -n scripts/macos-release-signing.sh
scripts/macos-release-signing.sh check
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ scrollback and motion settings — is in the

| Topic | Guide |
| --- | --- |
| Install, update, desktop app | [docs/install.md](docs/install.md) |
| Install, update, desktop app | [docs/install.md](docs/install.md) · [macOS release signing](docs/macos-release-signing.md) |
| Configuration, skills, MCP, sync | [docs/configuration.md](docs/configuration.md) |
| Providers and sign-in | [docs/provider-auth.md](docs/provider-auth.md) · [GitHub Copilot](docs/github-copilot.md) |
| Approvals and sandbox modes | [docs/sandbox.md](docs/sandbox.md) · [shell approvals](docs/shell-approvals.md) |
Expand Down
7 changes: 5 additions & 2 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ open target/swift-app/RustCode.app
```

Tagged releases publish `rustcode-macos-aarch64-swift-app.zip` alongside the
CLI archives. The bundle is ad-hoc signed but not notarized, so macOS
CLI archives. The bundle is ad-hoc signed but not notarized yet, so macOS
Gatekeeper warns when opening a downloaded copy; right-click it and choose
Open, or clear the quarantine flag with
`xattr -d com.apple.quarantine RustCode.app`.
`xattr -d com.apple.quarantine RustCode.app`. The release workflow switches
to Developer ID signing and notarization once its signing secrets are set
(see [macOS release signing](macos-release-signing.md)); this workaround is
removed from here with the first notarized release.

Questions the agent asks (`ask_question`) appear as cards: one button per
option, ticks and Submit for multi-select questions, and a free-text answer.
Expand Down
86 changes: 86 additions & 0 deletions docs/macos-release-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# macOS release signing and notarization

Tagged releases publish two macOS assets: the CLI archive
`rustcode-macos-aarch64.tar.gz` (also used by the Homebrew formula) and the
SwiftUI app `rustcode-macos-aarch64-swift-app.zip`, which bundles the same
engine as `Contents/Helpers/rustcode`.

The release workflow (`.github/workflows/build.yml`) signs both with a
Developer ID and notarizes them **only when all five secrets below are set**.
Until then it prints a notice and ships them as before: the app ad-hoc
signed, the CLI as the linker signed it, neither notarized. If only some of
the secrets are set the job fails, so a half-finished setup is not mistaken
for a signed release.

## What the workflow does

`scripts/macos-release-signing.sh` holds the steps:

1. `check` decides whether signing is on and reports missing secrets.
2. `import` decodes the `.p12` into a temporary keychain under
`$RUNNER_TEMP` and exports its `Developer ID Application` identity as
`MACOS_SIGN_IDENTITY`. `cleanup` deletes the keychain at the end of the
job, also after a failure.
3. CLI (`build` job, macOS): sign `rustcode` with the hardened runtime and a
secure timestamp, then submit it to the notary service in a zip. A bare
executable cannot be stapled; Gatekeeper fetches its ticket online.
4. App (`swift-app` job): `scripts/package-swift-app.sh` sees
`MACOS_SIGN_IDENTITY` and signs `Contents/Helpers/rustcode`, then the
bundle, both with `--options runtime --timestamp`. `notarize` submits the
app with `xcrun notarytool submit --wait`, fails unless the result is
`Accepted` (printing the notary log), staples the ticket, then runs
`xcrun stapler validate` and `spctl --assess --type execute`.
5. The released zip is unpacked again, given the quarantine flag a browser
download sets, and checked with `stapler validate`, `spctl --assess` and
`codesign --verify --deep --strict`.

No entitlements are needed: the app is not sandboxed, and neither executable
uses JIT or loads libraries signed by someone else. Add an entitlements file
only if that changes (for example `com.apple.security.cs.disable-library-validation`
for third-party plug-ins).

## Secrets

Notarization uses an Apple ID with an app-specific password. (An App Store
Connect API key would work too, but needs a different `notarytool` call; it
is not wired up.)

| Secret | Value |
| --- | --- |
| `MACOS_DEVELOPER_ID_P12_BASE64` | The **Developer ID Application** certificate with its private key, exported as `.p12` and base64 encoded: `base64 -i DeveloperID.p12 \| pbcopy` |
| `MACOS_DEVELOPER_ID_P12_PASSWORD` | The password chosen when exporting the `.p12` |
| `MACOS_NOTARY_APPLE_ID` | The Apple ID email of an Apple Developer Program member of the team |
| `MACOS_NOTARY_TEAM_ID` | The 10-character Team ID (developer.apple.com → Membership details) |
| `MACOS_NOTARY_PASSWORD` | An app-specific password for that Apple ID (account.apple.com → Sign-In and Security → App-Specific Passwords) |

## Setup

1. In the Apple Developer account, create a **Developer ID Application**
certificate (Certificates, IDs & Profiles → +). Only the Account Holder
can create one. Install it in the login keychain.
2. In Keychain Access, select the certificate together with its private key
and export it as `DeveloperID.p12` with a strong password.
3. Create the app-specific password.
4. Add the secrets to the repository, for example:

```bash
gh secret set MACOS_DEVELOPER_ID_P12_BASE64 < <(base64 -i DeveloperID.p12)
gh secret set MACOS_DEVELOPER_ID_P12_PASSWORD
gh secret set MACOS_NOTARY_APPLE_ID
gh secret set MACOS_NOTARY_TEAM_ID
gh secret set MACOS_NOTARY_PASSWORD
```

5. Run the Build workflow with **Run workflow** (`workflow_dispatch`) on
`main` and check that the `swift-app` job ends with `spctl` reporting
`accepted` and `source=Notarized Developer ID`. Delete the local `.p12`.
6. Once a release is signed, remove the quarantine workaround from
[install.md](install.md#native-desktop-app).

To check a downloaded release by hand:

```bash
ditto -x -k rustcode-macos-aarch64-swift-app.zip .
spctl --assess --type execute --verbose=4 RustCode.app
xcrun stapler validate RustCode.app
```
144 changes: 144 additions & 0 deletions scripts/macos-release-signing.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Developer ID signing and notarization for the macOS release assets.
# Used by .github/workflows/build.yml; see docs/macos-release-signing.md.
#
# Usage:
# scripts/macos-release-signing.sh check
# Exit 0 and print `enabled=true|false` (also to $GITHUB_OUTPUT) after
# checking which signing secrets are set. Fails when only some are.
# scripts/macos-release-signing.sh import
# Import the Developer ID certificate into a temporary keychain and
# export MACOS_SIGN_IDENTITY (to $GITHUB_ENV when set).
# scripts/macos-release-signing.sh sign <binary>
# Sign one executable with the hardened runtime and a secure timestamp.
# scripts/macos-release-signing.sh notarize <file.zip|App.app>
# Submit to the notary service and wait. An app is stapled and checked
# with spctl; a zip (a bare binary) cannot be stapled and is only
# submitted.
# scripts/macos-release-signing.sh cleanup
# Delete the temporary keychain.
#
# Secrets (environment): MACOS_DEVELOPER_ID_P12_BASE64,
# MACOS_DEVELOPER_ID_P12_PASSWORD, MACOS_NOTARY_APPLE_ID,
# MACOS_NOTARY_TEAM_ID, MACOS_NOTARY_PASSWORD (an app-specific password).
set -euo pipefail

SECRETS=(
MACOS_DEVELOPER_ID_P12_BASE64
MACOS_DEVELOPER_ID_P12_PASSWORD
MACOS_NOTARY_APPLE_ID
MACOS_NOTARY_TEAM_ID
MACOS_NOTARY_PASSWORD
)
KEYCHAIN="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/rustcode-signing.keychain-db"

usage() {
sed -n '2,24p' "$0" >&2
exit 2
}

check() {
local present=() missing=() name
for name in "${SECRETS[@]}"; do
if [[ -n "${!name:-}" ]]; then present+=("$name"); else missing+=("$name"); fi
done
local enabled=false
if ((${#missing[@]} == 0)); then
enabled=true
elif ((${#present[@]} > 0)); then
echo "::error::Developer ID signing is half configured; missing secrets: ${missing[*]}"
exit 1
else
echo "::notice::Developer ID signing secrets are not set; macOS assets stay ad-hoc signed and are not notarized. See docs/macos-release-signing.md."
fi
echo "enabled=$enabled"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then echo "enabled=$enabled" >>"$GITHUB_OUTPUT"; fi
}

require_secrets() {
local name
for name in "${SECRETS[@]}"; do
[[ -n "${!name:-}" ]] || { echo "$name is not set" >&2; exit 1; }
done
}

import() {
require_secrets
local password certificate
password="$(openssl rand -base64 24)"
certificate="$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/developer-id.XXXXXX")"
printf '%s' "$MACOS_DEVELOPER_ID_P12_BASE64" | base64 --decode >"$certificate"
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
security create-keychain -p "$password" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$password" "$KEYCHAIN"
security import "$certificate" -k "$KEYCHAIN" -P "$MACOS_DEVELOPER_ID_P12_PASSWORD" \
-f pkcs12 -T /usr/bin/codesign -T /usr/bin/security
rm -f "$certificate"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$KEYCHAIN" >/dev/null
# Put the temporary keychain first in the search list, keeping the others.
local existing=()
while IFS= read -r line; do
line="${line//\"/}"
line="${line#"${line%%[![:space:]]*}"}"
[[ -n "$line" ]] && existing+=("$line")
done < <(security list-keychains -d user)
security list-keychains -d user -s "$KEYCHAIN" ${existing[@]+"${existing[@]}"}
local identity
identity="$(security find-identity -v -p codesigning "$KEYCHAIN" \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')"
[[ -n "$identity" ]] || { echo "No Developer ID Application identity in the certificate" >&2; exit 1; }
echo "Signing identity: $identity"
if [[ -n "${GITHUB_ENV:-}" ]]; then echo "MACOS_SIGN_IDENTITY=$identity" >>"$GITHUB_ENV"; fi
}

sign() {
local binary="${1:?binary}"
: "${MACOS_SIGN_IDENTITY:?run import first}"
codesign --force --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$binary"
codesign --verify --strict --verbose=2 "$binary"
}

notarize() {
local target="${1:?file.zip or App.app}" upload result status id
require_secrets
upload="$target"
if [[ "$target" == *.app ]]; then
upload="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/$(basename "$target" .app)-notarize.zip"
ditto -c -k --sequesterRsrc --keepParent "$target" "$upload"
fi
result="$(xcrun notarytool submit "$upload" \
--apple-id "$MACOS_NOTARY_APPLE_ID" \
--team-id "$MACOS_NOTARY_TEAM_ID" \
--password "$MACOS_NOTARY_PASSWORD" \
--wait --timeout 30m --output-format json)"
status="$(printf '%s' "$result" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))')"
id="$(printf '%s' "$result" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))')"
echo "Notarization $id: $status"
if [[ "$status" != "Accepted" ]]; then
[[ -z "$id" ]] || xcrun notarytool log "$id" \
--apple-id "$MACOS_NOTARY_APPLE_ID" \
--team-id "$MACOS_NOTARY_TEAM_ID" \
--password "$MACOS_NOTARY_PASSWORD" || true
exit 1
fi
if [[ "$target" == *.app ]]; then
rm -f "$upload"
xcrun stapler staple "$target"
xcrun stapler validate "$target"
spctl --assess --type execute --verbose=4 "$target"
fi
}

cleanup() {
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
}

case "${1:-}" in
check) check ;;
import) import ;;
sign) shift; sign "$@" ;;
notarize) shift; notarize "$@" ;;
cleanup) cleanup ;;
*) usage ;;
esac
Loading
Loading