mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(desktop): sign-macos.sh signs, notarizes and staples the app and its DMG
The release DMG is signed with the owner's Developer ID and notarized, so a
downloaded copy opens after macOS's usual confirmation instead of stopping at
Gatekeeper. The work is a script of its own, so CI and a Mac run it the same way:
SIGN_IDENTITY=... NOTARY_PROFILE=<profile> \
packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>
It re-signs the app with the hardened runtime, a secure timestamp and
macos/entitlements.plist, checks the signature is a Developer ID one with the
runtime flag, has notarytool notarize it and staples the ticket, builds the DMG
around the stapled app, signs it, notarizes and staples it too, and has spctl
assess both. Codeman.dmg and Codeman.dmg.sha256 reach <out-dir> only after every
check passed; a failed run removes an older pair there rather than leave one to
upload by mistake. A submission Apple does not accept stops the script with its
id and Apple's log. The notary login is either a notarytool keychain profile
(NOTARY_PROFILE, NOTARY_KEYCHAIN) or APPLE_ID, APPLE_APP_PASSWORD and
APPLE_TEAM_ID; `sign-macos.sh --check` tests that without doing anything.
The DMG layout (the app next to an /Applications link, UDZO, volume "Codeman")
moves to make-dmg.sh, which both scripts call, and gains a retry: hosted macOS
runners now and then fail hdiutil create with "Resource busy". bundle-macos.sh
keeps its SIGN_IDENTITY path as it was; its new --notarize checks the
environment before building, then hands the app to sign-macos.sh.
packages/desktop/README.md says the release DMG is notarized and how to do
the same by hand, and drops the CI build and notarization from its ideas list.
All three scripts are bash 3.2 clean, and shellcheck reports nothing. With the
macOS tools stubbed, sign-macos.sh was run under bash 3.2 through a full
signing, a notarization rejected for the app and for the DMG, two hdiutil
failures then a pass, three failures, a relative out-dir and every missing
setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e262b04d1
commit
94912241bc
4 files changed
+218
-12
No files matched your search
@@ -213,10 +213,12 @@ and the Dock icon bounces.
|
||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||
copy in Applications works too. Shipping it to other people needs a Developer
|
||||
ID signature and notarization:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`,
|
||||
then `xcrun notarytool submit` and `xcrun stapler staple`.
|
||||
copy in Applications works too. The `Codeman.dmg` on each GitHub release is
|
||||
signed with a Developer ID and notarized by CI
|
||||
(`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the
|
||||
same by hand:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
|
||||
where `scripts/sign-macos.sh` lists what it needs.
|
||||
- **Local Network permission (macOS 15 and later).** The first connection to an
|
||||
address on the local network (a LAN IP, a `.local` name) makes macOS ask
|
||||
whether Codeman may use the local network, and that first attempt can fail
|
||||
@@ -278,4 +280,4 @@ and the Dock icon bounces.
|
||||
- Trusting a self-signed server certificate by its fingerprint.
|
||||
- Asking before a download that a rendered HTML file or a web tab starts.
|
||||
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
|
||||
- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update.
|
||||
- Windows and Linux builds, auto-update.
|
||||
@@ -4,6 +4,8 @@
|
||||
# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture
|
||||
# packages/desktop/scripts/bundle-macos.sh --universal --dmg
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> \
|
||||
# packages/desktop/scripts/bundle-macos.sh --universal --notarize
|
||||
#
|
||||
# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built
|
||||
# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15
|
||||
@@ -11,6 +13,15 @@
|
||||
# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open.
|
||||
# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy
|
||||
# in /Applications. A Developer ID signature + notarization avoids all of this.
|
||||
#
|
||||
# --dmg writes dist/Codeman-<version>.dmg, signed with SIGN_IDENTITY when it is
|
||||
# set. --notarize hands the built app to scripts/sign-macos.sh, which signs it
|
||||
# with SIGN_IDENTITY, has Apple notarize it, staples the ticket to it and does
|
||||
# the same for dist/Codeman.dmg, the DMG built around it (so --dmg is implied).
|
||||
# sign-macos.sh's header lists what it needs: SIGN_IDENTITY, and NOTARY_PROFILE
|
||||
# or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID. The release workflow
|
||||
# (.github/workflows/desktop-dmg.yml) runs this script ad hoc in one job and
|
||||
# sign-macos.sh in another, so the job that runs cargo holds no secret.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
@@ -18,14 +29,21 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
UNIVERSAL=0
|
||||
DMG=0
|
||||
NOTARIZE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--universal) UNIVERSAL=1 ;;
|
||||
--dmg) DMG=1 ;;
|
||||
--notarize) NOTARIZE=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
# Checked before the build, so a missing secret costs seconds, not a build.
|
||||
./scripts/sign-macos.sh --check
|
||||
fi
|
||||
|
||||
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
@@ -98,13 +116,13 @@ codesign --verify --strict "$APP"
|
||||
|
||||
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
|
||||
|
||||
if [ "$DMG" = 1 ]; then
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
./scripts/sign-macos.sh "$APP" dist
|
||||
elif [ "$DMG" = 1 ]; then
|
||||
DMG_PATH="dist/Codeman-$VERSION.dmg"
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
|
||||
./scripts/make-dmg.sh "$APP" "$DMG_PATH"
|
||||
if [ -n "${SIGN_IDENTITY:-}" ]; then
|
||||
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
|
||||
fi
|
||||
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
|
||||
fi
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Put Codeman.app in a compressed disk image next to a link to /Applications,
|
||||
# so opening the image shows the app and the folder to drag it into.
|
||||
#
|
||||
# packages/desktop/scripts/make-dmg.sh <Codeman.app> <out.dmg>
|
||||
#
|
||||
# bundle-macos.sh (--dmg) and sign-macos.sh both call it, so a local test DMG
|
||||
# and the release DMG are laid out the same way. It signs nothing.
|
||||
set -euo pipefail
|
||||
|
||||
[ "$(uname -s)" = Darwin ] || { echo "make-dmg.sh runs on macOS only" >&2; exit 1; }
|
||||
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out.dmg>" >&2; exit 2; }
|
||||
|
||||
APP=$1
|
||||
DMG=$2
|
||||
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
# ditto keeps what cp -R can lose: extended attributes, and with them a
|
||||
# stapled notarization ticket.
|
||||
ditto "$APP" "$STAGE/$(basename "$APP")"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
|
||||
# Hosted macOS runners now and then fail hdiutil with "Resource busy"; the
|
||||
# same command a few seconds later passes.
|
||||
rm -f "$DMG"
|
||||
tries=1
|
||||
until hdiutil create -quiet -volname Codeman -srcfolder "$STAGE" -ov -format UDZO "$DMG"; do
|
||||
if [ "$tries" -ge 3 ]; then
|
||||
echo "hdiutil create failed $tries times" >&2
|
||||
exit 1
|
||||
fi
|
||||
tries=$((tries + 1))
|
||||
echo "hdiutil create failed, trying again in 10 seconds" >&2
|
||||
rm -f "$DMG"
|
||||
sleep 10
|
||||
done
|
||||
hdiutil verify -quiet "$DMG"
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sign Codeman.app with a Developer ID, have Apple notarize it and staple the
|
||||
# ticket to it, then build Codeman.dmg around the stapled app and do the same
|
||||
# for the DMG. On macOS.
|
||||
#
|
||||
# packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>
|
||||
# packages/desktop/scripts/sign-macos.sh --check # only check the environment below
|
||||
#
|
||||
# <Codeman.app> is re-signed and stapled in place. <out-dir> receives
|
||||
# Codeman.dmg and Codeman.dmg.sha256, the latter with the bare file name in it,
|
||||
# so `shasum -a 256 -c Codeman.dmg.sha256` works next to a downloaded copy. Both
|
||||
# appear only once every check passed; a failed run leaves neither, not even an
|
||||
# older pair, so nothing half-done can be uploaded by mistake.
|
||||
#
|
||||
# The environment names who signs:
|
||||
# SIGN_IDENTITY a "Developer ID Application: ..." identity in a keychain
|
||||
# on the search list, by name or by the SHA-1 hash that
|
||||
# `security find-identity -v -p codesigning` prints
|
||||
# and how to reach Apple's notary service, either through a profile saved with
|
||||
# `xcrun notarytool store-credentials <profile> --apple-id ... --team-id ...`:
|
||||
# NOTARY_PROFILE the profile's name
|
||||
# NOTARY_KEYCHAIN optional, the keychain that holds it (default: login)
|
||||
# or with the Apple ID itself:
|
||||
# APPLE_ID, APPLE_APP_PASSWORD (an app-specific password), APPLE_TEAM_ID
|
||||
#
|
||||
# By hand, on a Mac with the certificate in the login keychain:
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=codeman-notary \
|
||||
# packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app packages/desktop/dist
|
||||
#
|
||||
# The release workflow (.github/workflows/desktop-dmg.yml) runs it in a job that
|
||||
# builds nothing: the job that runs cargo, and with it the build script of every
|
||||
# crate in Cargo.lock, never holds a signing secret.
|
||||
set -euo pipefail
|
||||
|
||||
[ "$(uname -s)" = Darwin ] || { echo "sign-macos.sh runs on macOS only" >&2; exit 1; }
|
||||
|
||||
DESKTOP=$(cd "$(dirname "$0")/.." && pwd)
|
||||
ENTITLEMENTS="$DESKTOP/macos/entitlements.plist"
|
||||
|
||||
# Fails with the name of what is missing, before any work, so a missing secret
|
||||
# costs seconds rather than a build.
|
||||
check_env() {
|
||||
if [ -z "${SIGN_IDENTITY:-}" ]; then
|
||||
echo "sign-macos.sh needs SIGN_IDENTITY" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
local name
|
||||
for name in APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
|
||||
if [ -z "$(eval "printf '%s' \"\${$name:-}\"")" ]; then
|
||||
echo "sign-macos.sh needs NOTARY_PROFILE, or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID ($name is empty)" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${1:-}" = --check ] && [ $# -eq 1 ]; then
|
||||
check_env
|
||||
exit 0
|
||||
fi
|
||||
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out-dir> | --check" >&2; exit 2; }
|
||||
check_env
|
||||
|
||||
APP=$1
|
||||
OUT=$2
|
||||
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
|
||||
mkdir -p "$OUT"
|
||||
OUT=$(cd "$OUT" && pwd)
|
||||
rm -f "$OUT/Codeman.dmg" "$OUT/Codeman.dmg.sha256"
|
||||
NAME=$(basename "$APP" .app)
|
||||
|
||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||
NOTARY_AUTH=(--keychain-profile "$NOTARY_PROFILE")
|
||||
if [ -n "${NOTARY_KEYCHAIN:-}" ]; then
|
||||
NOTARY_AUTH=("${NOTARY_AUTH[@]}" --keychain "$NOTARY_KEYCHAIN")
|
||||
fi
|
||||
else
|
||||
# On notarytool's command line, which the job's log never shows.
|
||||
NOTARY_AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID")
|
||||
fi
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Submit a file to the notary service and wait. Anything but Accepted stops the
|
||||
# script with the submission id and Apple's log, which names the file and the
|
||||
# reason (an unsigned binary, a missing timestamp, no hardened runtime...).
|
||||
notarize() {
|
||||
local file=$1 result="$WORK/notary.plist" rc=0 status id
|
||||
echo "Submitting $(basename "$file") to Apple's notary service"
|
||||
xcrun notarytool submit "$file" "${NOTARY_AUTH[@]}" --wait --timeout 45m \
|
||||
--output-format plist > "$result" || rc=$?
|
||||
status=$(/usr/libexec/PlistBuddy -c 'Print :status' "$result" 2>/dev/null || true)
|
||||
id=$(/usr/libexec/PlistBuddy -c 'Print :id' "$result" 2>/dev/null || true)
|
||||
if [ "$status" != Accepted ]; then
|
||||
echo "Notarization of $(basename "$file") ended as '${status:-unknown}' (submission ${id:-none}, notarytool exit $rc)" >&2
|
||||
cat "$result" >&2 || true
|
||||
if [ -n "$id" ]; then
|
||||
xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "Notarized $(basename "$file") (submission $id)"
|
||||
}
|
||||
|
||||
# The app: hardened runtime (which notarization requires), a secure timestamp,
|
||||
# and the entitlements the hardened runtime needs for microphone and camera.
|
||||
codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \
|
||||
--sign "$SIGN_IDENTITY" "$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
DETAILS=$(codesign --display --verbose=2 "$APP" 2>&1)
|
||||
printf '%s\n' "$DETAILS" | grep -E '^(Authority|TeamIdentifier|Timestamp)=' || true
|
||||
case "$DETAILS" in
|
||||
*"Authority=Developer ID Application: "*) ;;
|
||||
*) echo "$APP is not signed with a Developer ID Application certificate" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$DETAILS" in
|
||||
*"(runtime)"*) ;;
|
||||
*) echo "$APP is signed without the hardened runtime" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
ditto -c -k --keepParent "$APP" "$WORK/$NAME.zip"
|
||||
notarize "$WORK/$NAME.zip"
|
||||
xcrun stapler staple "$APP"
|
||||
xcrun stapler validate "$APP"
|
||||
spctl --assess --type execute --verbose=2 "$APP"
|
||||
|
||||
# The DMG around the stapled app, signed and notarized in its own right, so the
|
||||
# image opens without a warning and the app inside opens offline.
|
||||
DMG="$WORK/Codeman.dmg"
|
||||
"$DESKTOP/scripts/make-dmg.sh" "$APP" "$DMG"
|
||||
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG"
|
||||
codesign --verify --strict --verbose=2 "$DMG"
|
||||
notarize "$DMG"
|
||||
xcrun stapler staple "$DMG"
|
||||
xcrun stapler validate "$DMG"
|
||||
spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG"
|
||||
|
||||
(cd "$WORK" && shasum -a 256 Codeman.dmg > Codeman.dmg.sha256)
|
||||
mv "$DMG" "$WORK/Codeman.dmg.sha256" "$OUT/"
|
||||
echo "Signed and notarized $APP"
|
||||
echo "Built $OUT/Codeman.dmg ($(du -h "$OUT/Codeman.dmg" | cut -f1)), sha256 $(cut -d' ' -f1 "$OUT/Codeman.dmg.sha256")"
|
||||
Reference in new issue
Block a user