Skip to content

Commit 6e768cb

Browse files
authored
ci: sign + notarize releases via Developer ID + App Store Connect API key
- Pin all third-party actions to commit SHAs (matches claude-skills, hugo-theme-pager, blog convention) - Add Resources/MicGuard.entitlements (empty dict) for hardened runtime - Parameterize scripts/bundle.sh on SIGNING_IDENTITY and ENTITLEMENTS env vars; defaults preserve local ad-hoc-signed builds - Add scripts/release-notarize.sh with two auth modes: --key/--key-id/--issuer (CI) and --keychain-profile NAME (local), strictly validated - Workflow imports the .p12 into a temporary keychain, decodes the .p8 from a base64 secret, builds + signs + zips, notarizes + staples + refreshes the zip, runs codesign --verify / spctl --assess / stapler validate, publishes via SHA-pinned action, and deletes the keychain + key in an always() block - Bind the build job to a `release` GitHub Environment with required reviewer = repo owner; tag pushes pause for manual approval before any signing material is decrypted - Update docs/releasing.md to cover the new prerequisites, the Approve-deployment step, and the local-release fallback
1 parent 7f59390 commit 6e768cb

5 files changed

Lines changed: 311 additions & 9 deletions

File tree

.github/workflows/release.yml

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,106 @@
11
name: Release
2+
3+
# Builds, signs, notarizes, and publishes a MicGuard release zip.
4+
#
5+
# Required repo secrets (one-time setup):
6+
# BUILD_CERTIFICATE_BASE64 - base64 of Developer ID Application .p12
7+
# P12_PASSWORD - password protecting the .p12
8+
# KEYCHAIN_PASSWORD - random string for the temporary keychain
9+
# SIGNING_IDENTITY - "Developer ID Application: Name (TEAMID)"
10+
# APP_STORE_CONNECT_KEY_BASE64 - base64 of the .p8 App Store Connect API key
11+
# APP_STORE_CONNECT_KEY_ID - 10-char Key ID
12+
# APP_STORE_CONNECT_ISSUER - issuer UUID
13+
#
14+
# Required GitHub Environment: `release`. The job binds to it so a tag push
15+
# pauses for manual approval before any signing material is decrypted on the
16+
# runner. Configure the environment at Settings -> Environments -> release
17+
# with required reviewer = the repo owner.
18+
#
19+
# The published zip is built by `zip -ry` post-staple so the notarization
20+
# ticket travels with the archive and Gatekeeper accepts the bundle offline
21+
# on first launch.
22+
223
on:
324
push:
425
tags: ['v*']
26+
527
permissions:
628
contents: write
29+
730
jobs:
831
build:
932
runs-on: macos-15
33+
environment: release
1034
steps:
11-
- uses: actions/checkout@v4
12-
- run: make zip
13-
- uses: softprops/action-gh-release@v2
35+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
36+
with:
37+
fetch-depth: 0
38+
39+
- name: Import signing certificate
40+
env:
41+
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
42+
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
43+
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
44+
run: |
45+
set -euo pipefail
46+
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
47+
CERT_PATH="$RUNNER_TEMP/cert.p12"
48+
49+
echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH"
50+
51+
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
52+
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
53+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
54+
security import "$CERT_PATH" -P "$P12_PASSWORD" \
55+
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
56+
security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain
57+
security set-key-partition-list -S apple-tool:,apple:,codesign: \
58+
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
59+
rm -f "$CERT_PATH"
60+
61+
- name: Write App Store Connect API key
62+
env:
63+
APP_STORE_CONNECT_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
64+
run: |
65+
set -euo pipefail
66+
KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
67+
echo "$APP_STORE_CONNECT_KEY_BASE64" | base64 --decode > "$KEY_PATH"
68+
chmod 600 "$KEY_PATH"
69+
70+
- name: Build, sign, and zip
71+
env:
72+
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
73+
ENTITLEMENTS: Resources/MicGuard.entitlements
74+
run: make zip
75+
76+
- name: Notarize, staple, and refresh zip
77+
env:
78+
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
79+
APP_STORE_CONNECT_ISSUER: ${{ secrets.APP_STORE_CONNECT_ISSUER }}
80+
run: |
81+
scripts/release-notarize.sh \
82+
--key "$RUNNER_TEMP/AuthKey.p8" \
83+
--key-id "$APP_STORE_CONNECT_KEY_ID" \
84+
--issuer "$APP_STORE_CONNECT_ISSUER"
85+
86+
- name: Verify signature and notarization
87+
run: |
88+
set -euo pipefail
89+
codesign --verify --strict --verbose=2 .build/MicGuard.app
90+
spctl --assess --type execute --verbose .build/MicGuard.app
91+
xcrun stapler validate .build/MicGuard.app
92+
93+
- name: Publish release
94+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
1495
with:
1596
files: .build/MicGuard.zip
97+
98+
- name: Clean up secrets on runner
99+
if: always()
100+
run: |
101+
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
102+
KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
103+
if [[ -f "$KEYCHAIN_PATH" ]]; then
104+
security delete-keychain "$KEYCHAIN_PATH" || true
105+
fi
106+
rm -f "$KEY_PATH"

Resources/MicGuard.entitlements

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
</dict>
6+
</plist>

docs/releasing.md

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,31 +12,49 @@ How to publish a new MicGuard version to Homebrew.
1212

1313
The app version is derived from the latest git tag at build time. `scripts/bundle.sh` runs `git describe --tags` and stamps the result into `Info.plist` via `PlistBuddy`. The source `Info.plist` contains a `0.0.0-dev` placeholder — do not hardcode a version there.
1414

15+
## Prerequisites
16+
17+
The release workflow needs these one-time-setup repo secrets:
18+
19+
- `BUILD_CERTIFICATE_BASE64` - base64 of the Developer ID Application `.p12`
20+
- `P12_PASSWORD` - password protecting the `.p12`
21+
- `KEYCHAIN_PASSWORD` - random string for the temporary runner keychain
22+
- `SIGNING_IDENTITY` - `Developer ID Application: <Name> (<TEAMID>)`
23+
- `APP_STORE_CONNECT_KEY_BASE64` - base64 of the `.p8` App Store Connect API key
24+
- `APP_STORE_CONNECT_KEY_ID` - 10-char Key ID
25+
- `APP_STORE_CONNECT_ISSUER` - issuer UUID
26+
27+
Plus a GitHub Environment named `release` with required reviewer set to the repo owner. The workflow binds the build job to this environment, so a tag push pauses for manual approval before any signing material is decrypted on the runner.
28+
1529
## 1. Tag and push
1630

1731
```bash
1832
git tag v<version>
1933
git push origin v<version>
2034
```
2135

22-
GitHub Actions builds the app and creates a release with `MicGuard.zip`. The tag determines the version embedded in the built app.
36+
The `Release` workflow starts and pauses on the `release` environment, waiting for approval.
2337

24-
## 2. Get the SHA-256
38+
## 2. Approve the deployment
39+
40+
Open the workflow run on GitHub. The `build` job will be in `Waiting` state with a `Review deployments` button. Click it, select `release`, and approve. The job then imports the certificate, builds, signs, notarizes, staples, verifies, and publishes the release with `MicGuard.zip`.
41+
42+
## 3. Get the SHA-256
2543

2644
Once the release is published, compute the checksum:
2745

2846
```bash
2947
curl -sL https://github.com/pszypowicz/MicGuard/releases/download/v<version>/MicGuard.zip | shasum -a 256
3048
```
3149

32-
## 3. Update the Homebrew cask
50+
## 4. Update the Homebrew cask
3351

3452
In the [`homebrew-tap`](https://github.com/pszypowicz/homebrew-tap) repo, edit `Casks/mic-guard.rb`:
3553

3654
```ruby
3755
cask "mic-guard" do
3856
version "<version>"
39-
sha256 "<sha256 from step 2>"
57+
sha256 "<sha256 from step 3>"
4058

4159
url "https://github.com/pszypowicz/MicGuard/releases/download/v#{version}/MicGuard.zip"
4260
# ...
@@ -45,9 +63,30 @@ end
4563

4664
Update both the `version` and `sha256` fields, then commit and push.
4765

48-
## 4. Verify
66+
## 5. Verify
4967

5068
```bash
5169
brew update
5270
brew upgrade mic-guard
5371
```
72+
73+
## Local release (fallback)
74+
75+
If the CI path is broken or you want to release from your Mac, use [`scripts/release-notarize.sh`](https://github.com/pszypowicz/MicGuard/blob/main/scripts/release-notarize.sh). One-time setup:
76+
77+
```bash
78+
xcrun notarytool store-credentials MicGuard \
79+
--key /path/to/AuthKey_XXXXXXXXXX.p8 \
80+
--key-id XXXXXXXXXX \
81+
--issuer YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY
82+
```
83+
84+
Per-release:
85+
86+
```bash
87+
SIGNING_IDENTITY='Developer ID Application: <Name> (<TEAMID>)' \
88+
ENTITLEMENTS=Resources/MicGuard.entitlements \
89+
make zip
90+
scripts/release-notarize.sh --keychain-profile MicGuard
91+
gh release create v<version> .build/MicGuard.zip --generate-notes
92+
```

scripts/bundle.sh

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,21 @@ cp Sources/MicGuard/Info.plist "$APP/Info.plist"
1818
cp Resources/MicGuard.icns "$APP/Resources/MicGuard.icns"
1919
cp Resources/com.pszypowicz.MicGuard.agent.plist "$APP/Library/LaunchAgents/"
2020

21-
codesign --sign - --force --options runtime .build/MicGuard.app
21+
# Codesign with hardened runtime. Identity defaults to ad-hoc ("-") so local
22+
# `make build` works without a Developer ID; the release workflow exports
23+
# SIGNING_IDENTITY="Developer ID Application: ..." and ENTITLEMENTS to produce
24+
# a notarizable bundle. --timestamp is required for notarization, but only
25+
# valid when signing with a real identity (not ad-hoc).
26+
SIGNING_IDENTITY="${SIGNING_IDENTITY:--}"
27+
ENTITLEMENTS="${ENTITLEMENTS:-}"
28+
sign_args=(--force --options runtime --sign "$SIGNING_IDENTITY")
29+
if [[ "$SIGNING_IDENTITY" != "-" ]]; then
30+
sign_args+=(--timestamp)
31+
fi
32+
if [[ -n "$ENTITLEMENTS" ]]; then
33+
sign_args+=(--entitlements "$ENTITLEMENTS")
34+
fi
35+
codesign "${sign_args[@]}" .build/MicGuard.app
2236

2337
# Create bin/mic-guard symlink for CLI usage (included in zip for cask binary stanza)
2438
mkdir -p .build/bin

scripts/release-notarize.sh

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env bash
2+
#
3+
# release-notarize.sh - submit MicGuard.zip to Apple notarization, staple the
4+
# resulting ticket onto the .app, and re-zip so the published archive ships
5+
# with offline-verifiable notarization.
6+
#
7+
# Two authentication modes (pick one):
8+
#
9+
# 1. App Store Connect API key (recommended for CI):
10+
# --key PATH --key-id ID --issuer UUID
11+
#
12+
# 2. Keychain profile (recommended for local: run
13+
# `xcrun notarytool store-credentials NAME ...` once, then reference NAME):
14+
# --keychain-profile NAME
15+
#
16+
# notarytool's --wait flag polls until Apple returns a terminal status, so
17+
# this script does not implement its own polling loop. The submission log is
18+
# fetched on rejection so the failure reason ends up in CI output.
19+
#
20+
# Exit codes:
21+
# 0 notarized + stapled, zip refreshed
22+
# 1 notarization rejected by Apple, or stapler failed
23+
# 2 bad usage (missing/conflicting flags, file not found)
24+
25+
set -euo pipefail
26+
27+
usage() {
28+
cat <<'USAGE'
29+
Usage:
30+
release-notarize.sh --key PATH --key-id ID --issuer UUID [--app PATH] [--zip PATH]
31+
release-notarize.sh --keychain-profile NAME [--app PATH] [--zip PATH]
32+
33+
Submit a signed .app/.zip to Apple notarization, staple the ticket, and
34+
refresh the zip so the published archive carries the notarization offline.
35+
36+
Authentication (choose exactly one):
37+
--key PATH path to App Store Connect API key (.p8)
38+
--key-id ID 10-char Key ID from App Store Connect
39+
--issuer UUID issuer UUID from App Store Connect
40+
--keychain-profile NAME profile name set up via `notarytool store-credentials`
41+
42+
Optional:
43+
--app PATH path to the .app bundle (default: .build/MicGuard.app)
44+
--zip PATH path to the zip to submit (default: .build/MicGuard.zip)
45+
-h, --help show this help and exit
46+
47+
Examples:
48+
# CI / explicit credentials
49+
release-notarize.sh --key ./AuthKey_ABCDEF1234.p8 \
50+
--key-id ABCDEF1234 --issuer 12345678-1234-1234-1234-123456789012
51+
52+
# Local, after running `xcrun notarytool store-credentials MicGuard ...`
53+
release-notarize.sh --keychain-profile MicGuard
54+
USAGE
55+
}
56+
57+
APP=".build/MicGuard.app"
58+
ZIP=".build/MicGuard.zip"
59+
KEY=""
60+
KEY_ID=""
61+
ISSUER=""
62+
KEYCHAIN_PROFILE=""
63+
64+
while [[ $# -gt 0 ]]; do
65+
case "$1" in
66+
--app) APP="$2"; shift 2 ;;
67+
--zip) ZIP="$2"; shift 2 ;;
68+
--key) KEY="$2"; shift 2 ;;
69+
--key-id) KEY_ID="$2"; shift 2 ;;
70+
--issuer) ISSUER="$2"; shift 2 ;;
71+
--keychain-profile) KEYCHAIN_PROFILE="$2"; shift 2 ;;
72+
-h|--help) usage; exit 0 ;;
73+
*) echo "error: unknown flag: $1" >&2; usage >&2; exit 2 ;;
74+
esac
75+
done
76+
77+
# Pick auth mode: keychain profile XOR (key + key-id + issuer).
78+
api_key_mode_count=0
79+
[[ -n "$KEY" ]] && api_key_mode_count=$((api_key_mode_count + 1))
80+
[[ -n "$KEY_ID" ]] && api_key_mode_count=$((api_key_mode_count + 1))
81+
[[ -n "$ISSUER" ]] && api_key_mode_count=$((api_key_mode_count + 1))
82+
83+
if [[ -n "$KEYCHAIN_PROFILE" ]]; then
84+
if (( api_key_mode_count > 0 )); then
85+
echo "error: --keychain-profile cannot be combined with --key/--key-id/--issuer" >&2
86+
usage >&2
87+
exit 2
88+
fi
89+
auth_args=(--keychain-profile "$KEYCHAIN_PROFILE")
90+
elif (( api_key_mode_count == 3 )); then
91+
[[ -f "$KEY" ]] || { echo "error: API key not found: $KEY" >&2; exit 2; }
92+
auth_args=(--key "$KEY" --key-id "$KEY_ID" --issuer "$ISSUER")
93+
elif (( api_key_mode_count == 0 )); then
94+
echo "error: missing authentication; pass --keychain-profile NAME or all of --key/--key-id/--issuer" >&2
95+
usage >&2
96+
exit 2
97+
else
98+
echo "error: --key, --key-id, and --issuer must be passed together" >&2
99+
usage >&2
100+
exit 2
101+
fi
102+
103+
[[ -e "$APP" ]] || { echo "error: app bundle not found: $APP" >&2; exit 2; }
104+
[[ -f "$ZIP" ]] || { echo "error: zip not found: $ZIP" >&2; exit 2; }
105+
106+
echo "==> submitting $ZIP to notarization (waiting for Apple)"
107+
submit_log=$(mktemp)
108+
trap 'rm -f "$submit_log"' EXIT
109+
110+
if ! xcrun notarytool submit "$ZIP" \
111+
"${auth_args[@]}" \
112+
--wait \
113+
--output-format plist \
114+
> "$submit_log"; then
115+
echo "error: notarytool submit failed" >&2
116+
cat "$submit_log" >&2
117+
exit 1
118+
fi
119+
120+
status=$(/usr/libexec/PlistBuddy -c "Print :status" "$submit_log" 2>/dev/null || echo "")
121+
submission_id=$(/usr/libexec/PlistBuddy -c "Print :id" "$submit_log" 2>/dev/null || echo "")
122+
123+
echo "==> notarytool status: ${status:-unknown} (submission ${submission_id:-unknown})"
124+
125+
if [[ "$status" != "Accepted" ]]; then
126+
echo "error: notarization not accepted" >&2
127+
if [[ -n "$submission_id" ]]; then
128+
echo "==> fetching submission log:" >&2
129+
xcrun notarytool log "$submission_id" "${auth_args[@]}" >&2 || true
130+
fi
131+
exit 1
132+
fi
133+
134+
echo "==> stapling ticket onto $APP"
135+
xcrun stapler staple "$APP"
136+
137+
echo "==> validating staple"
138+
xcrun stapler validate "$APP"
139+
140+
echo "==> refreshing $ZIP with stapled bundle"
141+
zip_dir=$(dirname "$ZIP")
142+
zip_name=$(basename "$ZIP")
143+
app_name=$(basename "$APP")
144+
# Match the layout produced by `make zip` so the homebrew cask's `binary`
145+
# stanza pointing at `bin/mic-guard` keeps working after notarization.
146+
(
147+
cd "$zip_dir"
148+
rm -f "$zip_name"
149+
zip -ry "$zip_name" "$app_name" bin/mic-guard
150+
)
151+
152+
echo "==> done: $ZIP is signed, notarized, stapled"

0 commit comments

Comments
 (0)