diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b2812ec..8004389 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,8 +22,9 @@ jobs: runs-on: ${{ matrix.runner }} # Backstop against a hung build (e.g. a stalled Zig compile on the Intel # runner) so the job fails fast and frees the runner instead of sitting - # until the 6-hour Actions limit. A normal job finishes in well under 10m. - timeout-minutes: 30 + # until the 6-hour Actions limit. Raised from 30m to leave headroom for + # notarization, which can take several minutes per architecture. + timeout-minutes: 45 env: ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/architect/.zig-cache ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/architect/.zig-cache/local @@ -35,6 +36,30 @@ jobs: with: path: architect + - name: Verify code signing secrets + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + missing=() + [[ -z "$APPLE_CERTIFICATE_P12" ]] && missing+=(APPLE_CERTIFICATE_P12) + [[ -z "$APPLE_CERTIFICATE_PASSWORD" ]] && missing+=(APPLE_CERTIFICATE_PASSWORD) + [[ -z "$APPLE_SIGNING_IDENTITY" ]] && missing+=(APPLE_SIGNING_IDENTITY) + [[ -z "$APPLE_KEYCHAIN_PASSWORD" ]] && missing+=(APPLE_KEYCHAIN_PASSWORD) + [[ -z "$APPLE_API_KEY_ID" ]] && missing+=(APPLE_API_KEY_ID) + [[ -z "$APPLE_API_ISSUER_ID" ]] && missing+=(APPLE_API_ISSUER_ID) + [[ -z "$APPLE_API_KEY_P8" ]] && missing+=(APPLE_API_KEY_P8) + if [[ ${#missing[@]} -gt 0 ]]; then + printf '::error::Missing required signing/notarization secrets: %s\n' "${missing[*]}" + echo "See docs/development.md for setup instructions." + exit 1 + fi + - name: Install Nix uses: cachix/install-nix-action@v31 with: @@ -75,10 +100,50 @@ jobs: done working-directory: architect + - name: Import signing certificate + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/architect-signing.keychain-db" + CERTIFICATE_PATH="$RUNNER_TEMP/architect-certificate.p12" + trap 'rm -f "$CERTIFICATE_PATH"' EXIT + + ( umask 077 && printf '%s' "$APPLE_CERTIFICATE_P12" | base64 --decode -o "$CERTIFICATE_PATH" ) + + security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + - name: Bundle libraries and package + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | chmod +x scripts/bundle-macos.sh ./scripts/bundle-macos.sh zig-out/bin/architect release zig-out/bin/architect-mcp + working-directory: architect + + - name: Notarize application + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + APPLE_API_KEY_PATH="$RUNNER_TEMP/architect-notary-key.p8" + trap 'rm -f "$APPLE_API_KEY_PATH"' EXIT + ( umask 077 && printf '%s' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY_PATH" ) + chmod +x scripts/notarize-macos.sh + APPLE_API_KEY_PATH="$APPLE_API_KEY_PATH" ./scripts/notarize-macos.sh release/Architect.app + working-directory: architect + + - name: Package release archive + run: | cd release tar -czf architect-macos-${{ matrix.arch }}.tar.gz Architect.app working-directory: architect @@ -100,6 +165,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Clean up signing keychain + if: always() + run: | + if [[ -n "${KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + update-formula: needs: build-macos if: startsWith(github.ref, 'refs/tags/') diff --git a/CLAUDE.md b/CLAUDE.md index 05d8352..cbc3c5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,7 @@ The `<= len` pattern is only correct when `pos` represents a position *after* pr - Documentation updates should be part of the same PR as the code changes. ## Repo Notes +- The Release workflow (`.github/workflows/release.yaml`) requires seven `APPLE_*` GitHub secrets for Developer ID signing and notarization (`scripts/bundle-macos.sh`, `scripts/notarize-macos.sh`); it hard-fails fast if any are missing, including on `workflow_dispatch`. See `docs/development.md` "Code Signing and Notarization Setup" for the full list and setup steps. Local/dev use of `bundle-macos.sh` is unaffected and still ad-hoc signs by default. - Architect is a Zig app using the ghostty-vt dependency fetched via the Zig package manager; avoid reintroducing a checked-out `ghostty/` path assumption. - User config lives in `~/.config/architect/config.toml`. Maintain compatibility or add migrations when changing config shape. - `just` commands mirror zig builds (`just build`, `just run`, `just test`, `just ci`); use them when adjusting CI scripts or docs. diff --git a/README.md b/README.md index 190ce3c..c773eda 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ Download the latest release from the [releases page](https://github.com/forketyf ```bash curl -LO https://github.com/forketyfork/architect/releases/latest/download/architect-macos-arm64.tar.gz tar -xzf architect-macos-arm64.tar.gz -xattr -dr com.apple.quarantine Architect.app open Architect.app ``` @@ -69,14 +68,12 @@ open Architect.app ```bash curl -LO https://github.com/forketyfork/architect/releases/latest/download/architect-macos-x86_64.tar.gz tar -xzf architect-macos-x86_64.tar.gz -xattr -dr com.apple.quarantine Architect.app open Architect.app ``` **Note**: -* These GitHub release archives are ad-hoc signed so macOS can launch them locally, but they are not Developer ID signed or notarized. -* Clear the quarantine attribute before first launch, or macOS may block the app. +* These GitHub release archives are Developer ID signed and notarized by Apple, so macOS should open them without a Gatekeeper warning. * The archive contains `Architect.app`. You can launch it with `open Architect.app` or run `./Architect.app/Contents/MacOS/architect` from the terminal. The MCP helper is bundled at `./Architect.app/Contents/MacOS/architect-mcp`. Keep the bundle contents intact. * Not sure which architecture? Run `uname -m` - if it shows `arm64`, use the ARM64 version; if it shows `x86_64`, use the Intel version. @@ -108,6 +105,8 @@ brew install https://raw.githubusercontent.com/forketyfork/architect/main/Formul cp -r $(brew --prefix)/Cellar/architect/*/Architect.app /Applications/ ``` +**Note**: Homebrew builds `Architect.app` from source locally and does not sign or notarize it, unlike the [pre-built release binaries](#download-pre-built-binary-macos). If Gatekeeper blocks the app, run `xattr -dr com.apple.quarantine Architect.app` after copying it to `/Applications/`. + ### Build from Source See [`docs/development.md`](docs/development.md) for the full development setup. Quick start: @@ -163,7 +162,7 @@ Common settings include font family, theme colors, grid font scale, and logging ## Troubleshooting -* **App won't open (Gatekeeper)**: run `xattr -dr com.apple.quarantine Architect.app` after extracting the release. +* **App won't open (Gatekeeper)**: pre-built releases are notarized and shouldn't trigger this. For Homebrew or source builds (which are unsigned/ad-hoc), run `xattr -dr com.apple.quarantine Architect.app`. * **Font not found**: ensure the font is installed and set `font.family` in `config.toml`. The app falls back to `SFNSMono` on macOS. * **Missing symbol glyphs**: fallbacks try the bundled Symbols Nerd Font, then `Arial Unicode MS`, then `STIXTwoMath` (if available) before emoji. * **Emoji alignment**: single-codepoint emoji are centered using glyph metrics; if they appear off, try a different primary font or font size. diff --git a/docs/development.md b/docs/development.md index 9c3b3b6..9021ea9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,10 +95,54 @@ git tag v0.1.0 git push origin v0.1.0 ``` -The release workflow packages ad-hoc-signed app bundles with local `codesign --sign -`. It does not import macOS signing certificates, does not produce Developer ID-signed artifacts, and does not notarize the app. Release downloads therefore still require clearing the quarantine attribute after extraction, as described in the README installation instructions. You can also run the Release workflow manually with `workflow_dispatch` to validate the packaging flow before pushing a real release tag. +The release workflow signs each app bundle with a Developer ID Application certificate (hardened runtime + secure timestamp) and submits it to Apple's notary service before packaging, using [`scripts/bundle-macos.sh`](../scripts/bundle-macos.sh) and [`scripts/notarize-macos.sh`](../scripts/notarize-macos.sh). The workflow fails fast if the required secrets (below) are not configured — this applies to `workflow_dispatch` runs too, so a maintainer without the secrets set up cannot produce a release build. Local/dev use of `scripts/bundle-macos.sh` is unaffected: without `APPLE_SIGNING_IDENTITY` set, it still ad-hoc signs (`codesign --sign -`) as before. Each release includes: - `architect-macos-arm64.tar.gz` - Apple Silicon - `architect-macos-x86_64.tar.gz` - Intel -Each archive contains `Architect.app` with both `Contents/MacOS/architect` and the stdio MCP helper `Contents/MacOS/architect-mcp`. +Each archive contains `Architect.app` with both `Contents/MacOS/architect` and the stdio MCP helper `Contents/MacOS/architect-mcp`, notarized and stapled so Gatekeeper accepts them without clearing the quarantine attribute. + +### Code Signing and Notarization Setup + +The release workflow requires these GitHub Actions repository secrets: + +| Secret | Purpose | +| --- | --- | +| `APPLE_CERTIFICATE_P12` | Base64-encoded Developer ID Application certificate + private key (`.p12`) | +| `APPLE_CERTIFICATE_PASSWORD` | Password used when exporting the `.p12` | +| `APPLE_SIGNING_IDENTITY` | Certificate common name, e.g. `Developer ID Application: Jane Doe (TEAMID1234)` | +| `APPLE_KEYCHAIN_PASSWORD` | Password for the temporary CI keychain (any random string; only used within the job) | +| `APPLE_API_KEY_ID` | Key ID of an App Store Connect API key used for notarization | +| `APPLE_API_ISSUER_ID` | Issuer ID for the same API key | +| `APPLE_API_KEY_P8` | Full contents of the API key's `.p8` file | + +One-time setup, assuming an active Apple Developer Program membership: + +1. **Create the Developer ID Application certificate.** + - Open Keychain Access → Certificate Assistant → Request a Certificate from a Certificate Authority, save the CSR to disk. + - In [Apple Developer → Certificates](https://developer.apple.com/account/resources/certificates/list), create a new certificate, choose **Developer ID Application**, and upload the CSR. + - Download the issued certificate and double-click it to install it into your login keychain. +2. **Export it as a `.p12`.** + - In Keychain Access, find the certificate (it will show a disclosure triangle with the matching private key underneath), select both the certificate and the key, right-click → Export 2 items… + - Save as `architect-signing.p12` and set an export password — this becomes `APPLE_CERTIFICATE_PASSWORD`. +3. **Base64-encode the `.p12`** for storage as a secret: + ```bash + base64 -i architect-signing.p12 | pbcopy + ``` + Paste the result as `APPLE_CERTIFICATE_P12`. +4. **Determine the signing identity string:** + ```bash + security find-identity -v -p codesign + ``` + Copy the quoted name (e.g. `Developer ID Application: Jane Doe (TEAMID1234)`) as `APPLE_SIGNING_IDENTITY`. +5. **Create an App Store Connect API key for notarization.** + - Go to [App Store Connect → Users and Access → Integrations → Team Keys](https://appstoreconnect.apple.com/access/integrations/api). + - Create a new key with the **Developer** role (sufficient for notarization). + - Download the `.p8` file immediately — it can only be downloaded once. Its contents become `APPLE_API_KEY_P8`. + - Note the **Key ID** (`APPLE_API_KEY_ID`) and **Issuer ID** (`APPLE_API_ISSUER_ID`) shown on the same page. +6. **Pick a random string** for `APPLE_KEYCHAIN_PASSWORD` (e.g. `openssl rand -base64 32`); it only protects the ephemeral keychain created during the CI job. +7. **Add all seven secrets** under the repository's Settings → Secrets and variables → Actions. +8. Verify by running the Release workflow manually (`workflow_dispatch`) before pushing a real tag, or by pushing a tag once satisfied. + +Delete the local `.p12`/CSR files after uploading the secrets — the CI keychain that imports the certificate is created fresh and deleted at the end of every job run. diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index 6b5eef0..7fbc909 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -54,6 +54,30 @@ if [[ "$SIGN_APP" == true ]]; then fi fi +# Set APPLE_SIGNING_IDENTITY (e.g. "Developer ID Application: Name (TEAMID)") to +# sign with a real certificate; otherwise falls back to ad-hoc signing (`-`), which +# is only good for running the app locally and cannot be notarized. +SIGNING_IDENTITY="${APPLE_SIGNING_IDENTITY:--}" + +sign_binary() { + local target="$1" + local use_entitlements="$2" + + if [[ "$SIGNING_IDENTITY" == "-" ]]; then + if [[ "$use_entitlements" == true ]]; then + codesign --force --sign - --entitlements "$ENTITLEMENTS" "$target" + else + codesign --force --sign - "$target" + fi + else + if [[ "$use_entitlements" == true ]]; then + codesign --force --sign "$SIGNING_IDENTITY" --options runtime --timestamp --entitlements "$ENTITLEMENTS" "$target" + else + codesign --force --sign "$SIGNING_IDENTITY" --options runtime --timestamp "$target" + fi + fi +} + echo "Bundling macOS application: $EXECUTABLE -> $APP_DIR" echo "Including MCP helper: $MCP_EXECUTABLE" @@ -283,20 +307,24 @@ if [[ "$SIGN_APP" == true ]]; then shopt -s nullglob for lib in "$LIB_DIR"/*.dylib; do echo "Signing $(basename "$lib")..." - codesign --force --sign - "$lib" + sign_binary "$lib" false done shopt -u nullglob echo "Signing architect-mcp..." - codesign --force --sign - --entitlements "$ENTITLEMENTS" "$MACOS_DIR/architect-mcp" + sign_binary "$MACOS_DIR/architect-mcp" true echo "Signing architect..." - codesign --force --sign - --entitlements "$ENTITLEMENTS" "$MACOS_DIR/architect" + sign_binary "$MACOS_DIR/architect" true echo "Signing ${APP_NAME}.app..." - codesign --force --sign - --entitlements "$ENTITLEMENTS" "$APP_DIR" + sign_binary "$APP_DIR" true - echo "Code signing complete." + if [[ "$SIGNING_IDENTITY" == "-" ]]; then + echo "Code signing complete (ad-hoc; not notarizable)." + else + echo "Code signing complete with identity: $SIGNING_IDENTITY" + fi else echo "" echo "Removing embedded signatures (--unsigned)..." diff --git a/scripts/notarize-macos.sh b/scripts/notarize-macos.sh new file mode 100755 index 0000000..6e76c0b --- /dev/null +++ b/scripts/notarize-macos.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + echo "Requires APPLE_API_KEY_ID, APPLE_API_ISSUER_ID, APPLE_API_KEY_PATH in the environment." + exit 1 +fi + +APP_PATH="$1" + +: "${APPLE_API_KEY_ID:?APPLE_API_KEY_ID is required}" +: "${APPLE_API_ISSUER_ID:?APPLE_API_ISSUER_ID is required}" +: "${APPLE_API_KEY_PATH:?APPLE_API_KEY_PATH is required}" + +if [[ ! -d "$APP_PATH" ]]; then + echo "Error: app bundle not found: $APP_PATH" + exit 1 +fi + +WORK_DIR="$(mktemp -d)" +ZIP_PATH="$WORK_DIR/$(basename "${APP_PATH%.app}")-notarize.zip" +SUBMISSION_OUTPUT="$WORK_DIR/submission.json" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +echo "Zipping $APP_PATH for notarization submission..." +ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" + +echo "Submitting to Apple notary service (this can take a few minutes)..." +if ! xcrun notarytool submit "$ZIP_PATH" \ + --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --wait \ + --output-format json | tee "$SUBMISSION_OUTPUT"; then + echo "Notarization submission failed." >&2 + submission_id=$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$SUBMISSION_OUTPUT" | head -1) + if [[ -n "$submission_id" ]]; then + echo "Fetching notary log for submission $submission_id..." >&2 + xcrun notarytool log "$submission_id" \ + --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" || true + fi + exit 1 +fi + +echo "Stapling notarization ticket to $APP_PATH..." +xcrun stapler staple "$APP_PATH" + +echo "Validating stapled ticket..." +xcrun stapler validate "$APP_PATH" + +echo "Assessing Gatekeeper acceptance..." +spctl --assess --type execute --verbose "$APP_PATH" + +echo "Notarization complete."