Package 1.1.0: one PKG installs the CLI and the notarized app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 13:24:26 -05:00
parent 8d39bafb6f
commit e0d48e49e8
4 changed files with 89 additions and 38 deletions

View File

@@ -53,8 +53,10 @@ swift build # debug build of everything
- Package: tools-version 6.0, Swift 5 language mode, macOS 14 minimum. - Package: tools-version 6.0, Swift 5 language mode, macOS 14 minimum.
- `build.sh` reads `CODESIGN_APP_IDENTITY`, `CODESIGN_PKG_IDENTITY`, `NOTARY_PROFILE` (and optional - `build.sh` reads `CODESIGN_APP_IDENTITY`, `CODESIGN_PKG_IDENTITY`, `NOTARY_PROFILE` (and optional
`CODESIGN_TEAM_ID`) from the environment or an untracked `build.env`. Never hard-code identities. `CODESIGN_TEAM_ID`) from the environment or an untracked `build.env`. Never hard-code identities.
- The PKG is payload-free (`pkgbuild --nopayload --scripts`; `pkg/postinstall` copies the binary), because - The PKG is payload-free (`pkgbuild --nopayload --scripts`): `pkg/postinstall` installs the CLI to
any payload's "." entry would reset an existing `/usr/local/bin`'s ownership. `/usr/local/bin` and the notarized, stapled app to `/Applications`. A payload's "." entry is recorded as
root:wheel for its install location, which would reset an existing `/usr/local/bin` (Homebrew) or
`/Applications` (root:admin 775). The app is notarized and stapled before packaging; the PKG after.
- The app icon master is `Icon/AppIcon-1024.png`; `build.sh` generates `AppIcon.icns` from it. Don't redraw it. - The app icon master is `Icon/AppIcon-1024.png`; `build.sh` generates `AppIcon.icns` from it. Don't redraw it.
- Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=<queue>`, `CUPSKIT_LIVE_JOBS=<a>,<b>`, - Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=<queue>`, `CUPSKIT_LIVE_JOBS=<a>,<b>`,
`CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and `CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and

View File

@@ -6,14 +6,14 @@
## Install ## Install
> Status: the CLI is stable (1.0.x). The app is under active development — see [Releases](../../releases) for what's shipped. > Status: the CLI is stable. The app (1.1) covers printers, jobs, queue options and quick actions; printer discovery, classes, the server page and the Attributes/Log tabs are still to come — see [Releases](../../releases).
Download the signed, notarized PKG from the latest [release](../../releases). It installs: Download the signed, notarized PKG from the latest [release](../../releases). It installs:
- `/usr/local/bin/cupsadmin` - `/usr/local/bin/cupsadmin`
- `/Applications/CUPS Admin.app` (once the app ships) - `/Applications/CUPS Admin.app`
The package is payload-free and installs via a postinstall script, so it never changes the ownership of an existing `/usr/local/bin` (Homebrew on Intel Macs is safe). For Munki, use an `installs` array pointing at `/usr/local/bin/cupsadmin`; there is no payload file list to key off. The package is payload-free and installs both via a postinstall script, so it never changes the ownership of an existing `/usr/local/bin` (Homebrew on Intel Macs is safe) or of `/Applications`. For Munki, use an `installs` array pointing at `/usr/local/bin/cupsadmin` and `/Applications/CUPS Admin.app`; there is no payload file list to key off.
Requires macOS 14 or later. Universal (Apple silicon and Intel). Requires macOS 14 or later. Universal (Apple silicon and Intel).
@@ -98,7 +98,7 @@ VERSION=1.2.3 ./build.sh
Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win. Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win.
`build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs with the hardened runtime, builds the payload-free PKG, notarizes, staples and verifies with `spctl`. Use `./build.sh --build-only` to skip signing and notarization. `./build.sh --screenshots` regenerates the README screenshot from three temporary demo queues. Run `./test.sh` for the test suite (`CUPSKIT_LIVE_QUEUE=<throwaway queue>` enables the live tests; `CUPSKIT_LIVE_JOBS` and `CUPSKIT_LIVE_QUICK` take two throwaway queues each for the job and quick-action tests). `build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs the CLI and app with the hardened runtime, notarizes and staples the app, builds the payload-free PKG, notarizes and staples it, and verifies both with `spctl`. Use `./build.sh --build-only` to skip signing and notarization. `./build.sh --screenshots` regenerates the README screenshot from three temporary demo queues. Run `./test.sh` for the test suite (`CUPSKIT_LIVE_QUEUE=<throwaway queue>` enables the live tests; `CUPSKIT_LIVE_JOBS` and `CUPSKIT_LIVE_QUICK` take two throwaway queues each for the job and quick-action tests).
## Notes for admins ## Notes for admins

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Universal release build -> signed CLI binary + signed "CUPS Admin.app" -> signed payload-free PKG # Universal release build -> signed CLI + signed, notarized, stapled "CUPS Admin.app" -> signed payload-free PKG
# (postinstall installs /usr/local/bin/cupsadmin) -> notarize -> staple -> spctl. # (postinstall installs /usr/local/bin/cupsadmin and /Applications/CUPS Admin.app) -> notarize -> staple -> spctl.
# Usage: ./build.sh full pipeline (VERSION=1.2.0 ./build.sh to set the version) # Usage: ./build.sh full pipeline (VERSION=1.2.0 ./build.sh to set the version)
# ./build.sh --build-only build CLI and app, ad-hoc signed; no identities, no pkg, no notarization # ./build.sh --build-only build CLI and app, ad-hoc signed; no identities, no pkg, no notarization
# ./build.sh --screenshots build like --build-only, then regenerate docs/screenshots/app-jobs.png # ./build.sh --screenshots build like --build-only, then regenerate docs/screenshots/app-jobs.png
@@ -113,7 +113,7 @@ SWIFT
# --- configuration ------------------------------------------------------------- # --- configuration -------------------------------------------------------------
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION="${VERSION:-1.0.1}" VERSION="${VERSION:-1.1.0}"
IDENTIFIER="edu.wku.cupsadmin" IDENTIFIER="edu.wku.cupsadmin"
# build.env fills in only what the environment doesn't already set. # build.env fills in only what the environment doesn't already set.
@@ -268,11 +268,53 @@ if [ $BUILD_ONLY -eq 1 ]; then
exit 0 exit 0
fi fi
# --- notarize helper --------------------------------------------------------------
# notarize <file> <label>: submits, waits, saves the log; fails the build unless Accepted.
notarize() {
local file="$1" label="$2"
local json="$DIST/notary-$label.json"
xcrun notarytool submit "$file" --keychain-profile "$NOTARY_PROFILE" --wait --output-format json > "$json" || true
local status id
status=$(plutil -extract status raw "$json" 2>/dev/null || echo "unknown")
id=$(plutil -extract id raw "$json" 2>/dev/null || echo "")
echo "$label submission $id: $status"
if [ -n "$id" ]; then
xcrun notarytool log "$id" --keychain-profile "$NOTARY_PROFILE" "$DIST/notary-$label-log.json" >/dev/null 2>&1 || true
fi
[ "$status" = "Accepted" ] \
|| { STATUS="ERROR: $label notarization $status (see $json and $DIST/notary-$label-log.json)"; exit 1; }
}
# spctl_check <type> <path> <log>: must be accepted as Notarized Developer ID.
spctl_check() {
local type="$1" path="$2" log="$3" out
out=$(spctl -a -vv -t "$type" "$path" 2>&1 || true)
echo "$out" | tee "$log"
echo "$out" | grep -q ': accepted' && echo "$out" | grep -q 'source=Notarized Developer ID' \
|| { STATUS="ERROR: spctl did not accept $path as Notarized Developer ID"; exit 1; }
}
# --- notarize + staple app --------------------------------------------------------
step "notarize app (uploads to Apple and waits)"
APP_ZIP="$DIST/CUPS-Admin-$VERSION.zip"
rm -f "$APP_ZIP"
ditto -c -k --keepParent "$APP" "$APP_ZIP"
notarize "$APP_ZIP" app
rm -f "$APP_ZIP"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl_check execute "$APP" "$DIST/spctl-app.txt"
# --- build + sign pkg ----------------------------------------------------------- # --- build + sign pkg -----------------------------------------------------------
# Payload-free for the app too: an /Applications payload's "." entry is recorded as root:wheel, but
# /Applications is root:admin 775 — installing it could take away admins' drag-install rights.
# pkg/postinstall copies both the CLI and the stapled app instead.
step "build and sign pkg" step "build and sign pkg"
rm -f "$PKG" rm -f "$PKG"
cp pkg/postinstall "$SCRIPTS/postinstall" cp pkg/postinstall "$SCRIPTS/postinstall"
chmod 755 "$SCRIPTS/postinstall" chmod 755 "$SCRIPTS/postinstall"
rm -rf "$SCRIPTS/CUPS Admin.app"
ditto "$APP" "$SCRIPTS/CUPS Admin.app"
pkgbuild --nopayload --scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \ pkgbuild --nopayload --scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \
--sign "$INSTALLER_SIGN_ID" --timestamp "$PKG" --sign "$INSTALLER_SIGN_ID" --timestamp "$PKG"
pkgutil --check-signature "$PKG" pkgutil --check-signature "$PKG"
@@ -286,41 +328,33 @@ if [ -n "$(printf '%s' "$PAYLOAD_FILES" | grep -v -i 'no payload' || true)" ]; t
exit 1 exit 1
fi fi
EXPANDED="$DIST/expanded" EXPANDED="$DIST/expanded"
rm -rf "$EXPANDED"
pkgutil --expand "$PKG" "$EXPANDED" pkgutil --expand "$PKG" "$EXPANDED"
# pkgutil --expand unpacks the Scripts archive into a directory. # pkgutil --expand unpacks the Scripts archive into a directory.
echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')" echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')"
[ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] \ [ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] && [ -d "$EXPANDED/Scripts/CUPS Admin.app" ] \
|| { STATUS="ERROR: Scripts missing postinstall or cupsadmin"; exit 1; } || { STATUS="ERROR: Scripts missing postinstall, cupsadmin or CUPS Admin.app"; exit 1; }
codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \ codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \
|| { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; } || { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; }
echo "binary inside pkg: signature intact, $(lipo -archs "$EXPANDED/Scripts/cupsadmin")" codesign --verify --deep --strict "$EXPANDED/Scripts/CUPS Admin.app" \
|| { STATUS="ERROR: app inside pkg Scripts lost its signature"; exit 1; }
xcrun stapler validate "$EXPANDED/Scripts/CUPS Admin.app" >/dev/null \
|| { STATUS="ERROR: app inside pkg Scripts lost its stapled ticket"; exit 1; }
echo "inside pkg: cupsadmin signed ($(lipo -archs "$EXPANDED/Scripts/cupsadmin")), CUPS Admin.app signed and stapled"
rm -rf "$EXPANDED" rm -rf "$EXPANDED"
# --- notarize ------------------------------------------------------------------- # --- notarize + staple pkg ------------------------------------------------------
step "notarize (this uploads the pkg to Apple and waits)" step "notarize pkg (uploads to Apple and waits)"
SUBMIT_JSON="$DIST/notary-submit.json" notarize "$PKG" pkg
xcrun notarytool submit "$PKG" --keychain-profile "$NOTARY_PROFILE" --wait --output-format json > "$SUBMIT_JSON" || true
NOTARY_STATUS=$(plutil -extract status raw "$SUBMIT_JSON" 2>/dev/null || echo "unknown")
SUBMISSION_ID=$(plutil -extract id raw "$SUBMIT_JSON" 2>/dev/null || echo "")
echo "submission $SUBMISSION_ID: $NOTARY_STATUS"
if [ -n "$SUBMISSION_ID" ]; then
xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "$NOTARY_PROFILE" "$DIST/notary-log.json" >/dev/null 2>&1 || true
fi
if [ "$NOTARY_STATUS" != "Accepted" ]; then
STATUS="ERROR: notarization $NOTARY_STATUS (see $SUBMIT_JSON and $DIST/notary-log.json)"
exit 1
fi
# --- staple + verify ------------------------------------------------------------ step "staple pkg"
step "staple"
xcrun stapler staple "$PKG" xcrun stapler staple "$PKG"
xcrun stapler validate "$PKG" xcrun stapler validate "$PKG"
step "spctl assessment" step "spctl assessment"
spctl -a -vv -t install "$PKG" 2>&1 | tee "$DIST/spctl.txt" spctl_check install "$PKG" "$DIST/spctl.txt"
grep -q ': accepted' "$DIST/spctl.txt" && grep -q 'source=Notarized Developer ID' "$DIST/spctl.txt" \ spctl_check execute "$APP" "$DIST/spctl-app.txt"
|| { STATUS="ERROR: spctl did not accept $PKG as Notarized Developer ID"; exit 1; }
echo echo
echo "install: sudo installer -pkg $PKG -target /" echo "install: sudo installer -pkg $PKG -target /"
STATUS="OK: $PKG signed, notarized ($SUBMISSION_ID), stapled, spctl accepted" STATUS="OK: $PKG (CLI + CUPS Admin.app) signed, app and pkg notarized, stapled, spctl accepted"

View File

@@ -1,17 +1,32 @@
#!/bin/bash #!/bin/bash
# cupsadmin postinstall (payload-free pkg): the binary ships in the Scripts archive next to this file. # cupsadmin postinstall (payload-free pkg): the CLI binary and CUPS Admin.app ship in the Scripts
# Never changes ownership/permissions of an existing /usr/local/bin (Homebrew on Intel owns it). # archive next to this file. Never changes ownership/permissions of an existing /usr/local/bin
# (Homebrew on Intel owns it) or of /Applications (root:admin 775).
set -euo pipefail set -euo pipefail
TARGET="${3:-/}" TARGET="${3:-/}"
DEST_DIR="${TARGET%/}/usr/local/bin" HERE="$(cd "$(dirname "$0")" && pwd)"
SOURCE="$(cd "$(dirname "$0")" && pwd)/cupsadmin"
# --- CLI -------------------------------------------------------------------------
DEST_DIR="${TARGET%/}/usr/local/bin"
if [ ! -d "$DEST_DIR" ]; then if [ ! -d "$DEST_DIR" ]; then
mkdir -p -m 755 "$DEST_DIR" mkdir -p -m 755 "$DEST_DIR"
echo "cupsadmin: created $DEST_DIR" echo "cupsadmin: created $DEST_DIR"
fi fi
# -S: copy to a temp file and rename, so a running cupsadmin is never half-written. # -S: copy to a temp file and rename, so a running cupsadmin is never half-written.
install -S -m 755 -o root -g wheel "$SOURCE" "$DEST_DIR/cupsadmin" install -S -m 755 -o root -g wheel "$HERE/cupsadmin" "$DEST_DIR/cupsadmin"
echo "cupsadmin: installed $DEST_DIR/cupsadmin" echo "cupsadmin: installed $DEST_DIR/cupsadmin"
# --- app -------------------------------------------------------------------------
if [ -d "$HERE/CUPS Admin.app" ]; then
APPS="${TARGET%/}/Applications"
STAGE="$APPS/.CUPS Admin.app.installing"
rm -rf "$STAGE"
ditto "$HERE/CUPS Admin.app" "$STAGE"
chown -R root:wheel "$STAGE"
chmod -R go-w "$STAGE"
# Swap in place so a half-copied bundle is never at the real path.
rm -rf "$APPS/CUPS Admin.app"
mv "$STAGE" "$APPS/CUPS Admin.app"
echo "cupsadmin: installed $APPS/CUPS Admin.app"
fi