Skip to content

Commit 693ec4d

Browse files
committed
ci: end-to-end macOS code signing + notarization
Add the full signing infrastructure for macOS release binaries, both for local development and CI. This includes the bundle metadata, hardened-runtime entitlements, an updated sign script that handles both architectures, a CI job that imports a P12 cert into a temporary keychain and signs/notarizes, and a gh script to set the GitHub secrets. What's added: 1. rel/Info.plist (new) - Bundle metadata: CFBundleIdentifier, CFBundleName, CFBundleShortVersionString, CFBundleVersion, LSMinimumSystemVersion, LSUIElement, etc. - Apple's notary service accepts single-binary notarization without an Info.plist, but providing one is best practice and lets us wrap in a .app bundle later without rewriting. 2. bin/ado.entitlements (new) - Hardened-runtime entitlements required for BEAM: * com.apple.security.cs.allow-jit (BEAM JIT, MAP_JIT) * com.apple.security.cs.allow-unsigned-executable-memory (BEAM loadable modules) * com.apple.security.cs.disable-library-validation (NIFs / dynamic shared object loading) - Sandbox entitlements are intentionally NOT requested — this is a CLI tool, not a sandboxed app. 3. bin/sign.sh (rewritten) - Now signs BOTH macOS targets (aarch64 + x86_64), not just one - Uses the new --entitlements flag - Uses --options runtime --timestamp for hardened runtime - Validates the signing identity is actually in the keychain (catches typos early) - Adds --skip-notarize mode for fast local iteration - Reports spctl --assess at the end for each signed binary - Falls back to legacy 'ado_macos' / 'ado_macos_x86' filenames for older release artifacts 4. justfile (updated) - release-macos: comment updated to mention both variants - sign-macos: re-sign an existing build without rebuilding - sign-macos-fast: sign only, skip notarization - verify-macos: run codesign --verify + spctl --assess + stapler validate on every macOS binary in burrito_out/ 5. .github/workflows/ci.yml (updated) - Added 'sign: true|false' to each matrix entry (true for the 2 macOS jobs, false for the 2 Linux jobs) - New conditional steps that run only when sign: true: a. Import code-signing certificate - Creates a temporary keychain in $RUNNER_TEMP - Base64-decodes MACOS_CERT_P12_BASE64 into a .p12 file - Imports it with the MACOS_CERT_P12_PASSWORD - Sets the keychain as the default search list - Sets the keychain partition list (apple-tool:, apple:) b. Sign macOS binary - codesign --force --options runtime --timestamp --entitlements bin/ado.entitlements --sign $MACOS_SIGN_IDENTITY --keychain $MACOS_KEYCHAIN_PATH c. Notarize macOS binary - xcrun notarytool submit with --keychain-profile - xcrun stapler staple + validate - spctl --assess as final Gatekeeper check d. Clean up keychain (always-runs, even on failure) - If any of the MACOS_* secrets are missing, the step emits a ::warning:: and skips gracefully (so PR builds from forks / untrusted contributors still succeed) 6. SIGNING.md (rewritten) - Documents the .p12 export procedure - Documents the GitHub Secrets that need to be set - Documents the new justfile recipes - Documents the file layout (sign.sh, entitlements, Info.plist) - Step-by-step CI verification procedure 7. bin/setup-gh-secrets.sh (new) - Interactive script that uses 'gh secret set' to push the four MACOS_* secrets to the repo - Falls back to env vars if you set them in advance - Prints the available Developer ID identities from your keychain so you can copy/paste the right value - Prints the commands to set up the notarytool profile 8. README.md (updated) - File tree now lists rel/Info.plist, bin/ado.entitlements, and bin/sign.sh Verified locally: - actionlint clean - YAML parses - mix ci green (compile + format + credo + xref + dialyzer + tests) - 162 tests pass - 'just verify-macos' correctly identifies the unsigned state of the existing burrito_out/ binaries (expected) and reports the right failures
1 parent 55a73d0 commit 693ec4d

8 files changed

Lines changed: 691 additions & 103 deletions

File tree

.github/workflows/ci.yml

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,24 +201,28 @@ jobs:
201201
ext: ""
202202
label: "Linux x86_64"
203203
artifact: ado-linux-x86_64
204+
sign: "false"
204205
- os: ubuntu-24.04-arm
205206
target: linux_arm
206207
cpu: aarch64
207208
ext: ""
208209
label: "Linux ARM64"
209210
artifact: ado-linux-aarch64
211+
sign: "false"
210212
- os: macos-latest
211213
target: macos
212214
cpu: aarch64
213215
ext: ""
214216
label: "macOS Apple Silicon"
215217
artifact: ado-macos-aarch64
218+
sign: "true"
216219
- os: macos-latest
217220
target: macos_x86
218221
cpu: x86_64
219222
ext: ""
220223
label: "macOS Intel"
221224
artifact: ado-macos-x86_64
225+
sign: "true"
222226

223227
steps:
224228
- uses: actions/checkout@v4
@@ -342,6 +346,107 @@ jobs:
342346
file "$BINARY"
343347
"$BINARY" --version || "$BINARY" --help | head -20
344348
349+
# ── macOS code signing + notarization ─────────────────────────────
350+
# Only runs for matrix entries with sign: "true" (i.e. the two
351+
# macOS targets). Requires these GitHub Secrets:
352+
# MACOS_SIGN_IDENTITY e.g. "Developer ID Application: Foo (TEAMID1234)"
353+
# MACOS_CERT_P12_BASE64 base64 of the .p12 export of the cert
354+
# MACOS_CERT_P12_PASSWORD the .p12's export password
355+
# MACOS_KEYCHAIN_PROFILE name of a notarytool keychain profile
356+
# (set up locally with
357+
# `xcrun notarytool store-credentials`)
358+
# If any of these are missing on a non-tag push, the step
359+
# skips with a warning so PR builds still succeed.
360+
- name: Import code-signing certificate
361+
if: matrix.sign == 'true'
362+
env:
363+
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
364+
MACOS_CERT_P12_PASSWORD: ${{ secrets.MACOS_CERT_P12_PASSWORD }}
365+
run: |
366+
set -euo pipefail
367+
if [[ -z "${MACOS_CERT_P12_BASE64}" || -z "${MACOS_CERT_P12_PASSWORD}" ]]; then
368+
echo "::warning::MACOS_CERT_P12_BASE64 / MACOS_CERT_P12_PASSWORD secrets not set; skipping signing."
369+
echo "::warning::See SIGNING.md for the one-time setup."
370+
exit 0
371+
fi
372+
KEYCHAIN_PATH="$RUNNER_TEMP/ado-signing.keychain-db"
373+
KEYCHAIN_PASSWORD="$(openssl rand -hex 24)"
374+
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
375+
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
376+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
377+
# shellcheck disable=SC2046
378+
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
379+
echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
380+
security import "$RUNNER_TEMP/cert.p12" \
381+
-k "$KEYCHAIN_PATH" \
382+
-P "$MACOS_CERT_P12_PASSWORD" \
383+
-T /usr/bin/codesign \
384+
-T /usr/bin/security
385+
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
386+
echo "MACOS_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
387+
echo "MACOS_KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
388+
389+
- name: Sign macOS binary
390+
if: matrix.sign == 'true'
391+
env:
392+
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
393+
run: |
394+
set -euo pipefail
395+
if [[ -z "${MACOS_SIGN_IDENTITY}" ]]; then
396+
echo "::warning::MACOS_SIGN_IDENTITY secret not set; skipping signing."
397+
exit 0
398+
fi
399+
if [[ ! -f "$MACOS_KEYCHAIN_PATH" ]]; then
400+
echo "::warning::No keychain (secrets not set); skipping signing."
401+
exit 0
402+
fi
403+
BINARY=$(ls release_bin/ado-*)
404+
echo "Signing $BINARY with: $MACOS_SIGN_IDENTITY"
405+
codesign \
406+
--force \
407+
--options runtime \
408+
--timestamp \
409+
--entitlements bin/ado.entitlements \
410+
--sign "$MACOS_SIGN_IDENTITY" \
411+
--keychain "$MACOS_KEYCHAIN_PATH" \
412+
"$BINARY"
413+
echo "Verifying signature"
414+
codesign --verify --verbose=2 "$BINARY"
415+
codesign -d --entitlements - "$BINARY" 2>&1 | head -20
416+
417+
- name: Notarize macOS binary
418+
if: matrix.sign == 'true'
419+
env:
420+
MACOS_KEYCHAIN_PROFILE: ${{ secrets.MACOS_KEYCHAIN_PROFILE }}
421+
run: |
422+
set -euo pipefail
423+
if [[ -z "${MACOS_KEYCHAIN_PROFILE}" ]]; then
424+
echo "::warning::MACOS_KEYCHAIN_PROFILE secret not set; skipping notarization."
425+
echo "::warning::Signed binary is OK for local use but Gatekeeper will warn on first run."
426+
exit 0
427+
fi
428+
BINARY=$(ls release_bin/ado-*)
429+
echo "Submitting for notarization (profile: $MACOS_KEYCHAIN_PROFILE)"
430+
xcrun notarytool submit "$BINARY" \
431+
--keychain-profile "$MACOS_KEYCHAIN_PROFILE" \
432+
--keychain "$MACOS_KEYCHAIN_PATH" \
433+
--keychain-profile-password "$MACOS_KEYCHAIN_PASSWORD" \
434+
--wait
435+
echo "Stapling notarization ticket"
436+
xcrun stapler staple "$BINARY"
437+
xcrun stapler validate "$BINARY"
438+
echo "spctl assessment:"
439+
spctl --assess --verbose=2 "$BINARY"
440+
441+
- name: Clean up keychain
442+
if: always() && matrix.sign == 'true' && env.MACOS_KEYCHAIN_PATH != ''
443+
run: |
444+
if [[ -n "${MACOS_KEYCHAIN_PATH:-}" && -f "$MACOS_KEYCHAIN_PATH" ]]; then
445+
security delete-keychain "$MACOS_KEYCHAIN_PATH" || true
446+
# shellcheck disable=SC2046
447+
security list-keychains -d user -s $(security list-keychains -d user | grep -v ado-signing | tr -d '"') || true
448+
fi
449+
345450
- name: Upload release binary
346451
uses: actions/upload-artifact@v4
347452
with:

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,9 @@ ado_cli/
396396
├── .credo.exs # Credo configuration (strict mode)
397397
├── AGENTS.md # CI quality gate principles
398398
├── SIGNING.md # macOS code signing + notarization guide
399+
├── rel/Info.plist # Bundle metadata used during codesign
400+
├── bin/ado.entitlements # Hardened-runtime entitlements for BEAM
401+
├── bin/sign.sh # Sign + notarize script
399402
└── mix.exs # Project definition & aliases
400403
```
401404

SIGNING.md

Lines changed: 125 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,68 +6,146 @@ notarization must be done post-build using Apple's `codesign` and
66

77
## Prerequisites
88

9-
1. **Apple Developer Program** membership ($99/year)
10-
2. A **Developer ID Application** certificate in your keychain
11-
- Create one at <https://developer.apple.com/account/resources/certificates>
12-
- Verify with: `security find-identity -p codesigning`
13-
3. A **notarytool** keychain profile (recommended) **OR** Apple ID credentials:
14-
- **Profile (preferred)** — set up once:
15-
```sh
16-
xcrun notarytool store-credentials --apple-id you@example.com \
17-
--team-id ABCDE12345 --password <app-specific-password>
18-
```
19-
- **Or** set env vars each time (less secure, expires more often)
20-
21-
## Run
9+
### 1. Apple Developer Program
10+
11+
You need an **active** Apple Developer Program membership ($99/year) to
12+
issue a **"Developer ID Application"** certificate. A regular
13+
"Apple Development" certificate is **not** sufficient — that one is for
14+
App Store / development only and Apple will reject it for notarization.
15+
16+
Enroll at <https://developer.apple.com/programs/enroll/>.
17+
18+
### 2. Developer ID Application certificate
19+
20+
1. Create one at
21+
<https://developer.apple.com/account/resources/certificates>
22+
(choose "Developer ID Application", **not** "Apple Development")
23+
2. Download the `.cer` and double-click to install in your keychain
24+
3. Verify with: `security find-identity -p codesigning`
25+
- The output should include a line like:
26+
`1) ABC123... "Developer ID Application: Your Name (TEAMID1234)"`
27+
28+
### 3. Notarytool credentials (one-time local setup)
2229

2330
```sh
24-
# Build + sign + notarize
31+
xcrun notarytool store-credentials ado-notary \
32+
--apple-id you@example.com \
33+
--team-id ABCDE12345 \
34+
--password <app-specific-password-from-appleid.apple.com>
35+
```
36+
37+
The password is an **app-specific password** generated at
38+
<https://appleid.apple.com> → App-Specific Passwords. Do **not** use
39+
your Apple ID password.
40+
41+
The profile name (`ado-notary` above) is what you pass as
42+
`MACOS_KEYCHAIN_PROFILE` to the signing script and to CI.
43+
44+
## Run locally
45+
46+
```sh
47+
# Build + sign + notarize both macos binaries
2548
export MACOS_SIGN_IDENTITY="Developer ID Application: ACME Inc. (TEAMID)"
26-
export MACOS_KEYCHAIN_PROFILE=ado-notary # created with store-credentials
49+
export MACOS_KEYCHAIN_PROFILE=ado-notary
2750
just release-macos
2851

29-
# Or with raw credentials (no profile)
30-
export MACOS_NOTARY_APPLE_ID=you@example.com
31-
export MACOS_NOTARY_TEAM_ID=ABCDE12345
32-
export MACOS_NOTARY_PASSWORD=abcd-efgh-ijkl-mnop
33-
just release-macos
52+
# Or just sign (no notarization) for fast local iteration
53+
export MACOS_SIGN_IDENTITY="Developer ID Application: ACME Inc. (TEAMID)"
54+
just sign-macos-fast
55+
56+
# Or re-sign an existing build without rebuilding
57+
just sign-macos
58+
59+
# Verify the signed + notarized binaries pass Gatekeeper
60+
just verify-macos
3461
```
3562

36-
The script:
37-
1. Runs `codesign --options runtime --timestamp --sign`
38-
2. Verifies with `codesign --verify --verbose=2`
39-
3. Submits to Apple's notary service and waits for approval
40-
4. Stitches the notarization ticket onto the binary with `xcrun stapler staple`
41-
42-
## CI integration
43-
44-
Example GitLab CI job:
45-
46-
```yaml
47-
build:sign:
48-
stage: release
49-
script:
50-
- just release-macos
51-
artifacts:
52-
paths:
53-
- burrito_out/ado_macos
54-
expire_in: 30 days
63+
The script (`bin/sign.sh`) does, for each macOS binary in
64+
`burrito_out/`:
65+
66+
1. `codesign --force --options runtime --timestamp --entitlements bin/ado.entitlements --sign <identity>`
67+
2. `codesign --verify --verbose=2` — verify the signature is valid
68+
3. `codesign -d --entitlements -` — print the embedded entitlements
69+
4. `xcrun notarytool submit --keychain-profile ... --wait` — submit to Apple
70+
5. `xcrun stapler staple` — stitch the ticket onto the binary
71+
6. `xcrun stapler validate` — confirm the ticket is present
72+
7. `spctl --assess --verbose=2` — final Gatekeeper check
73+
74+
## Files this project ships for signing
75+
76+
| File | Purpose |
77+
|---|---|
78+
| `bin/sign.sh` | The sign + notarize script |
79+
| `bin/ado.entitlements` | Hardened-runtime entitlements for BEAM (allow-jit, allow-unsigned-executable-memory, disable-library-validation) |
80+
| `rel/Info.plist` | Bundle metadata (CFBundleIdentifier, etc.); referenced in `codesign` calls for full bundle signing if you wrap in a .app later |
81+
| `just release-macos` | One-shot: build, rename, sign, notarize |
82+
| `just sign-macos` | Sign + notarize an existing build |
83+
| `just sign-macos-fast` | Sign only, skip notarization (fast local iteration) |
84+
| `just verify-macos` | Run `codesign --verify` + `spctl --assess` + `stapler validate` |
85+
86+
## CI integration (GitHub Actions)
87+
88+
The release workflow (`.github/workflows/ci.yml`) automatically signs
89+
and notarizes both macOS targets when the required secrets are set.
90+
91+
### Required GitHub Secrets
92+
93+
Go to
94+
`https://github.com/<owner>/<repo>/settings/secrets/actions` and add:
95+
96+
| Secret | Value |
97+
|---|---|
98+
| `MACOS_SIGN_IDENTITY` | `Developer ID Application: Your Name (TEAMID1234)` |
99+
| `MACOS_CERT_P12_BASE64` | Base64-encoded `.p12` export of the Developer ID cert (see below) |
100+
| `MACOS_CERT_P12_PASSWORD` | The password you set when exporting the `.p12` |
101+
| `MACOS_KEYCHAIN_PROFILE` | The name of the notarytool profile (e.g. `ado-notary`) |
102+
103+
### One-time: export the .p12 for CI
104+
105+
```sh
106+
# 1. Open Keychain Access
107+
# 2. Find the "Developer ID Application: ..." cert
108+
# 3. Right-click → Export → save as .p12 (set a strong password)
109+
# 4. Base64-encode it for GitHub Secrets
110+
base64 -i ~/Desktop/cert.p12 | pbcopy
111+
# 5. Paste into the MACOS_CERT_P12_BASE64 secret
55112
```
56113

57-
Store credentials in CI variables (masked, protected):
58-
- `MACOS_SIGN_IDENTITY`
59-
- `MACOS_KEYCHAIN_PROFILE` (recommended) **OR** the three raw notary vars
60-
(`MACOS_NOTARY_APPLE_ID`, `MACOS_NOTARY_TEAM_ID`, `MACOS_NOTARY_PASSWORD`)
114+
### What the CI does
115+
116+
The release job on `macos-latest` (for both `macos` and `macos_x86`
117+
matrix entries):
61118

62-
## Verifying signatures
119+
1. Builds the binary via Burrito
120+
2. Renames to `ado-<version>-macos-<arch>`
121+
3. Smoke tests with `./ado --version`
122+
4. **Creates a temporary keychain**, imports the `.p12`
123+
5. **Codesigns** with the Developer ID + entitlements + hardened runtime
124+
6. **Submits to notarytool** with the keychain profile
125+
7. **Staples** the notarization ticket
126+
8. Runs `spctl --assess` to confirm Gatekeeper accepts
127+
9. **Cleans up the keychain** (always-runs, even on failure)
128+
10. Uploads the signed binary as a release artifact
129+
130+
If any of the signing secrets are missing, the step **skips with a
131+
warning** rather than failing — this lets the workflow succeed for PR
132+
builds and unblock development on non-macOS contributors.
133+
134+
### Verifying a CI-built binary
63135

64136
```sh
65-
codesign --verify --verbose=2 burrito_out/ado_macos
66-
codesign -d --entitlements - burrito_out/ado_macos
67-
spctl --assess --verbose burrito_out/ado_macos
137+
# Download from the GitHub Actions artifacts panel
138+
gh run download <run-id> -n ado-macos-aarch64
139+
140+
# Verify
141+
codesign --verify --verbose=4 ado-0.1.0-macos-aarch64
142+
codesign -d --entitlements - ado-0.1.0-macos-aarch64
143+
spctl --assess --verbose ado-0.1.0-macos-aarch64
144+
# Expected: "ado-0.1.0-macos-aarch64: accepted"
68145
```
69146

70147
## Reference
71148

72149
- <https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution>
150+
- <https://developer.apple.com/documentation/security/hardened_runtime_entitlements>
73151
- <https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution#Use-the-xcrun-notarytool-command>

bin/ado.entitlements

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
Entitlements for the `ado` CLI binary.
4+
5+
Hardened Runtime is enabled via `codesign --options runtime` in
6+
bin/sign.sh. Hardened Runtime restricts what a signed binary is
7+
allowed to do at runtime. By default this would prevent BEAM (the
8+
Erlang VM Burrito ships) from functioning, so we need to opt back
9+
in to specific capabilities via entitlements.
10+
11+
These are the minimum entitlements a BEAM-based CLI tool needs
12+
under Hardened Runtime:
13+
14+
- com.apple.security.cs.allow-jit
15+
BEAM compiles native code at runtime (the BEAM compiler,
16+
NIFs, etc.) and uses executable memory pages. The JIT
17+
entitlement lets the kernel map pages with MAP_JIT on
18+
Apple Silicon.
19+
20+
- com.apple.security.cs.allow-unsigned-executable-memory
21+
Allows BEAM to allocate pages marked PROT_EXEC | PROT_WRITE
22+
before writing machine code into them and flipping to
23+
PROT_EXEC. Required for BEAM's loadable modules.
24+
25+
- com.apple.security.cs.disable-library-validation
26+
Allows BEAM to dlopen() libraries that are not signed with
27+
the same Developer ID team. We don't use this directly, but
28+
some NIFs and Elixir libraries dynamically load shared
29+
objects at runtime. Disabling library validation is the
30+
standard escape hatch for "I trust my own code but I
31+
dlopen() other people's libs".
32+
33+
These are all "runtime" entitlements (set by --options runtime);
34+
the sandbox/file-access entitlements are intentionally NOT
35+
requested because this is a CLI tool, not a sandboxed GUI app.
36+
37+
Reference:
38+
https://developer.apple.com/documentation/security/hardened_runtime_entitlements
39+
https://developer.apple.com/documentation/security/hardened_runtime
40+
-->
41+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
42+
<plist version="1.0">
43+
<dict>
44+
<key>com.apple.security.cs.allow-jit</key>
45+
<true/>
46+
47+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
48+
<true/>
49+
50+
<key>com.apple.security.cs.disable-library-validation</key>
51+
<true/>
52+
</dict>
53+
</plist>

0 commit comments

Comments
 (0)