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
76 changes: 74 additions & 2 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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/')
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,19 @@ 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
```

**For Intel Macs:**
```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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 46 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 33 additions & 5 deletions scripts/bundle-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)..."
Expand Down
61 changes: 61 additions & 0 deletions scripts/notarize-macos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 1 ]]; then
echo "Usage: $0 <path-to-App.app>"
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."
Loading