Merge ci/macos-app-release: build, sign, notarize and attach Codeman.dmg on every release

release.yml and docs/releasing.md take the branch's text, which is master's text plus the macOS app additions (its first commit copied both files from master).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-08 04:07:05 +01:00
commit 31ce0c88cd
8 files changed
+718 -13

No files matched your search

+7 -5
View File
@@ -341,10 +341,12 @@ crosses, so the login is never on a command line.
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
@@ -420,4 +422,4 @@ crosses, so the login is never on a command line.
- 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.
+25 -7
View File
@@ -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
+42
View File
@@ -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"
+144
View File
@@ -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")"