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
90 changes: 90 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,96 @@ jobs:
fi
- name: build
run: cargo build --release -p reach --target ${{ matrix.target }}

# Building on a macOS runner already gets an AD-HOC signature from the
# linker, which is why `curl | sh` works: curl sets no quarantine
# attribute, so Gatekeeper never runs. A browser download does set one,
# and an ad-hoc signature is not a Developer ID, so the same tarball
# fetched from the releases page is refused with "the developer cannot
# be verified". That is what this signs away.
#
# This runs BEFORE `package`, and it has to: signing rewrites the
# binary, so a tarball built first would ship the unsigned one, and
# SHA256SUMS would still verify — install.sh would confirm the checksum
# of the wrong artifact and the failure would name the download.
- name: sign for Gatekeeper (Developer ID + hardened runtime)
if: matrix.target == 'aarch64-apple-darwin'
env:
CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
set -euo pipefail
# Fail loudly rather than shipping something Gatekeeper will refuse:
# a release that is quietly unsigned is discovered by a customer.
for v in CERT_P12_BASE64 CERT_PASSWORD SIGNING_IDENTITY; do
if [ -z "${!v}" ]; then
echo "::error::$v is not set — see README 'Signed macOS builds'." >&2
exit 1
fi
done
# A throwaway keychain, so nothing is left behind on a shared runner
# and no login keychain is touched.
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 900 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$CERT_PASSWORD" -T /usr/bin/codesign
rm -f "$RUNNER_TEMP/cert.p12"
# Without this codesign blocks on a GUI prompt no one can answer and
# the job hangs until it times out.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
# `--options runtime` is the hardened runtime, and notarization
# REJECTS a submission without it — with an error that does not say
# so plainly. `--timestamp` is likewise required.
codesign --force --options runtime --timestamp \
--keychain "$KEYCHAIN" \
--sign "$SIGNING_IDENTITY" \
"target/${{ matrix.target }}/release/reachpad"
codesign --verify --strict --verbose=2 \
"target/${{ matrix.target }}/release/reachpad"

# notarytool takes .zip, .pkg or .dmg — NOT the .tar.gz we ship — so the
# binary is zipped only to be submitted. What ships is still the tarball
# containing the same signed binary; the ticket is bound to the code
# signature, not to the container it was submitted in.
#
# A bare executable cannot be STAPLED (stapling needs a bundle, .dmg or
# .pkg), so Gatekeeper looks the ticket up online. That is correct but
# network-dependent: a first run on a machine with no route to Apple
# fails the same way an unnotarized binary does. Ship a stapled .pkg if
# that ever becomes a real complaint.
- name: notarize
if: matrix.target == 'aarch64-apple-darwin'
env:
API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: |
set -euo pipefail
for v in API_KEY_P8_BASE64 API_KEY_ID API_ISSUER_ID; do
if [ -z "${!v}" ]; then
echo "::error::$v is not set — see README 'Signed macOS builds'." >&2
exit 1
fi
done
echo "$API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/key.p8"
ditto -c -k "target/${{ matrix.target }}/release/reachpad" \
"$RUNNER_TEMP/notarize.zip"
# --wait so a rejection fails the release rather than being found
# later by whoever downloads it.
xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" \
--key "$RUNNER_TEMP/key.p8" \
--key-id "$API_KEY_ID" \
--issuer "$API_ISSUER_ID" \
--wait
rm -f "$RUNNER_TEMP/key.p8"

- name: package
run: |
cd target/${{ matrix.target }}/release
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@ cargo build --release -p reach
./target/release/reachpad --version
```

### Signed macOS builds

The macOS binary is signed with a Developer ID certificate and notarized by
Apple, so a download from the releases page opens without a Gatekeeper
warning. `curl | sh` never needed it: curl sets no quarantine attribute, so
Gatekeeper does not run on that path.

The binary is signed before the tarball is built, so the checksum in
SHA256SUMS covers the signed artifact — reverse the order and the install
script would verify the wrong one and blame the download.

A bare executable cannot be stapled (stapling needs a bundle, `.dmg` or
`.pkg`), so Gatekeeper resolves the notarization ticket online. A first run
on a machine that cannot reach Apple therefore fails the way an unnotarized
binary does. If that becomes a real complaint, ship a stapled `.pkg`
alongside the tarball.

Six repository secrets, and the release fails loudly rather than shipping
something Gatekeeper will refuse if any is missing:

| Secret | What it is |
|---|---|
| `APPLE_CERT_P12_BASE64` | `base64` of the exported **Developer ID Application** certificate and key. Not "Mac App Distribution" — that one is App Store only and will not satisfy Gatekeeper here. |
| `APPLE_CERT_PASSWORD` | The password set when exporting that `.p12`. |
| `APPLE_SIGNING_IDENTITY` | The identity string, e.g. `Developer ID Application: Tako Research (TEAMID)` — `security find-identity -v -p codesigning` prints it. |
| `APPLE_API_KEY_P8_BASE64` | `base64` of an App Store Connect API key (`.p8`). Preferred over an Apple ID and app-specific password, which break whenever someone's password or 2FA changes. |
| `APPLE_API_KEY_ID` | That key's ID. |
| `APPLE_API_ISSUER_ID` | The issuer ID from App Store Connect → Users and Access → Integrations. |

The certificate is generated once from the Apple Developer account and is
not in this repository or the private one; the workflow imports it into a
throwaway keychain and deletes it with the runner.

The snapshot is synced from a private monorepo on every release, so file an
issue rather than a PR for changes; a PR here would be overwritten by the
next sync (the sync script and its header in `Cargo.toml` say the same).
Expand Down
Loading