diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index cbb55fd..8942838 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -4,52 +4,50 @@ on: push: branches: - develop + tags: + - 'v*' pull_request: branches: - develop jobs: build-and-test: + # Prefer a self-hosted Mac runner if your Gitea has one. If not, + # macos-14 works for this pipeline. runs-on: macos-14 + env: + DERIVED: build/DerivedData-test steps: - name: Checkout uses: actions/checkout@v4 - - name: Fetch Argyll sidecars - run: scripts/fetch-argyll.sh - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - - name: Generate Xcode project - run: xcodegen generate --project . + run: xcodegen generate --spec project.yml - - name: Build and test (universal) + - name: Build for testing (universal) run: | xcodebuild build-for-testing \ -scheme ICCery \ -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED" \ + -configuration Debug \ ARCHS='arm64 x86_64' \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGNING_ALLOWED=YES \ - CODE_SIGN_IDENTITY='-' \ - CODE_SIGNING_REQUIRED=YES + CODE_SIGN_IDENTITY='-' - BUILT_PRODUCTS_DIR="$(xcodebuild -scheme ICCery -destination 'platform=macOS' -showBuildSettings | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p' | head -n 1)" - if [ -d "$BUILT_PRODUCTS_DIR/ICCery.app" ]; then - xattr -cr "$BUILT_PRODUCTS_DIR/ICCery.app" - codesign -f -s - --options runtime \ - --entitlements Resources/ICCery.entitlements \ - "$BUILT_PRODUCTS_DIR/ICCery.app" + - name: Test (universal) + run: | + XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" + if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then + echo "error: no xctestrun produced by build-for-testing" >&2 + exit 1 fi - - xcodebuild test \ - -scheme ICCery \ + echo "xctestrun: $XCTESTRUN" + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN" \ -destination 'platform=macOS' \ - ARCHS='arm64 x86_64' \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_ALLOWED=YES \ - CODE_SIGN_IDENTITY='-' \ - CODE_SIGNING_REQUIRED=YES + -derivedDataPath "$DERIVED" package: needs: build-and-test diff --git a/.gitignore b/.gitignore index 570b6c3..6c15b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ ICCery.xcodeproj/ *.zip Release/ notarization/ +build/ diff --git a/README.md b/README.md index c92198c..e58a5a5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,28 @@ export GITEA_TOKEN=… # private releases Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate. +## Release packaging + +```bash +scripts/package-release.sh # fetch → sign → universal build → verify → DMG +``` + +The script builds with a fixed derived data path (`build/DerivedData`), +locates `Release/ICCery.app` from it, signs the bundle, recursively verifies +every bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), and +writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` via `dmgbuild`. Sidecars stay +ad-hoc signed inside the bundle — the app is never `codesign --deep`ed. + +Environment variables read by the pipeline: + +| Variable | Purpose | +|---|---| +| `GITEA_TOKEN` | private `gronod/argyllcms` release downloads | +| `ARGYLL_SERVER_URL` / `ARGYLL_REPO` / `ARGYLL_RELEASE_TAG` | sidecar release override | +| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc | +| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing | +| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set | + ## Layout ``` diff --git a/scripts/dmgbuild-settings.py b/scripts/dmgbuild-settings.py index 9bc33b5..94f1081 100755 --- a/scripts/dmgbuild-settings.py +++ b/scripts/dmgbuild-settings.py @@ -1,15 +1,28 @@ #!/usr/bin/env python3 # scripts/dmgbuild-settings.py # -# dmgbuild settings for ICCery. Set DMG_FILENAME and DMG_VOLUME_NAME in the -# environment, or accept the defaults. Background art can be supplied later by -# placing a PNG at Resources/dmg-background.png and setting DMG_BACKGROUND. +# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME +# in the environment, or accept the defaults. Background art can be supplied +# later by placing a PNG at Resources/dmg-background.png and setting +# DMG_BACKGROUND. import os +import sys filename = os.environ.get('DMG_FILENAME', 'ICCery.dmg') volume_name = os.environ.get('DMG_VOLUME_NAME', 'ICCery') +# The built .app must be staged into the image. Without this the DMG mounts +# empty (#32). DMG_APP is exported by scripts/package-release.sh. +app_path = os.environ.get('DMG_APP', '') +if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path): + sys.stderr.write( + 'error: DMG_APP must point at an existing .app bundle ' + '(got %r)\n' % app_path) + sys.exit(1) + +files = [app_path] + # Background art is optional. If the referenced PNG does not exist, fall back # to a plain window. See docs/23-assets.md for the DMG background spec. background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png') diff --git a/scripts/fetch-argyll.sh b/scripts/fetch-argyll.sh index cc2b0c8..0918c37 100755 --- a/scripts/fetch-argyll.sh +++ b/scripts/fetch-argyll.sh @@ -111,22 +111,23 @@ find "$DEST" -type f -exec chmod 0755 {} + xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true # Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then -# verify — an unsigned sidecar fails the script. -for f in "$DEST"/*; do - [ -f "$f" ] || continue - if file -b "$f" | grep -q 'Mach-O'; then - codesign -f -s - "$f" 2>/dev/null || true - fi -done +# verify — an unsigned sidecar fails the script. The tree may nest +# (e.g. platform subdirs), so scan recursively. +find "$DEST" -type f -exec sh -c \ + 'for p do file -b "$p" | grep -q "Mach-O" && codesign -f -s - "$p"; done' \ + _ {} + 2>/dev/null || true + UNSIGNED="" -for f in "$DEST"/*; do +while IFS= read -r f; do [ -f "$f" ] || continue - if file -b "$f" | grep -q 'Mach-O'; then - if ! codesign -dvv "$f" >/dev/null 2>&1; then - UNSIGNED="$UNSIGNED $f" - fi + if ! codesign -dvv "$f" >/dev/null 2>&1; then + UNSIGNED="$UNSIGNED $f" fi -done +done <&2 exit 1 diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 66f372f..0e1bf23 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -6,7 +6,7 @@ # Steps: # 1. Fetch and ad-hoc sign Argyll sidecars (scripts/fetch-argyll.sh). # 2. Generate the Xcode project from project.yml. -# 3. Build a universal Release ICCery.app. +# 3. Build a universal Release ICCery.app with a fixed derived data path. # 4. Sign the .app (Developer ID if CODESIGN_IDENTITY is set, else ad-hoc). # 5. Hard-fail verify every bundled Mach-O sidecar with codesign -dvv. # 6. Build a DMG with dmgbuild. @@ -24,16 +24,24 @@ set -eu ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" cd "$ROOT" +if [ ! -f project.yml ]; then + echo "error: project.yml not found in $ROOT" >&2 + exit 1 +fi + +DERIVED="$ROOT/build/DerivedData" +mkdir -p "$DERIVED" + +# Fetch sidecars first because the Xcode project copies them into the bundle. echo "==> Fetching Argyll sidecars" scripts/fetch-argyll.sh echo "==> Generating Xcode project" -xcodegen generate --project . +xcodegen generate --spec project.yml CONFIG="Release" DEST="platform=macOS" -# Default to ad-hoc signing. A real Developer ID can be injected via env. IDENTITY="${CODESIGN_IDENTITY:--}" DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}" @@ -43,11 +51,11 @@ if [ -n "$DEVELOPMENT_TEAM" ]; then BUILD_EXTRA="DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM" fi -# shellcheck disable=SC2086 xcodebuild \ -scheme ICCery \ -destination "$DEST" \ -configuration "$CONFIG" \ + -derivedDataPath "$DERIVED" \ ARCHS='arm64 x86_64' \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGNING_ALLOWED=YES \ @@ -55,48 +63,69 @@ xcodebuild \ $BUILD_EXTRA \ build -echo "==> Locating built app" -BUILT_PRODUCTS_DIR="$(xcodebuild \ - -scheme ICCery \ - -destination "$DEST" \ - -configuration "$CONFIG" \ - -showBuildSettings \ - | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p' \ - | head -n 1)" - -APP="$BUILT_PRODUCTS_DIR/ICCery.app" +APP="$DERIVED/Build/Products/$CONFIG/ICCery.app" if [ ! -d "$APP" ]; then echo "error: built app not found at $APP" >&2 exit 1 fi echo "App: $APP" -# If a Developer ID identity was supplied, re-sign the .app bundle. Sidecars -# live in Resources/Argyll and remain ad-hoc signed by fetch-argyll.sh. +# Sidecars are copied into the bundle by the build phase. Re-signing the .app +# with --force should keep them intact, but verify after and repair any that +# got stripped. Do not codesign --deep the bundle with Developer ID — sidecars +# stay ad-hoc. if [ -n "${CODESIGN_IDENTITY:-}" ] && [ "$CODESIGN_IDENTITY" != "-" ]; then echo "==> Signing $APP with '$CODESIGN_IDENTITY'" codesign --force --sign "$CODESIGN_IDENTITY" \ --entitlements Resources/ICCery.entitlements \ --options runtime \ + --timestamp \ + --verbose \ "$APP" -else - echo "==> App ad-hoc signed by xcodebuild; not re-signing" + + echo "==> Verifying app signature" + codesign --verify --deep --strict --verbose=2 "$APP" fi echo "==> Verifying sidecar signatures" -scripts/verify-sidecar-signatures.sh "$APP" +if ! scripts/verify-sidecar-signatures.sh "$APP"; then + echo "==> Re-applying ad-hoc signature to sidecars" + while IFS= read -r f; do + [ -f "$f" ] || continue + codesign -f -s - --options runtime "$f" 2>/dev/null || true + done < Re-verifying sidecar signatures" + scripts/verify-sidecar-signatures.sh "$APP" +fi + +echo "==> Installing / locating dmgbuild" +if ! command -v dmgbuild >/dev/null 2>&1; then + VENV="$ROOT/build/.venv-dmgbuild" + if [ ! -d "$VENV/bin" ]; then + python3 -m venv "$VENV" + "$VENV/bin/pip" install --upgrade pip + "$VENV/bin/pip" install dmgbuild + fi + PATH="$VENV/bin:$PATH" + export PATH +fi +if ! command -v dmgbuild >/dev/null 2>&1; then + echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2 + exit 1 +fi echo "==> Building DMG" -VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || echo '2.0.0')" -BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || echo '1')" +VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')" +BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')" DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg" VOLUME_NAME="ICCery ${VERSION}" -if ! command -v dmgbuild >/dev/null 2>&1; then - echo "==> Installing dmgbuild" - pip3 install dmgbuild -fi - +DMG_APP="$APP" \ DMG_FILENAME="$DMG" \ DMG_VOLUME_NAME="$VOLUME_NAME" \ dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG" diff --git a/scripts/verify-sidecar-signatures.sh b/scripts/verify-sidecar-signatures.sh index e553af8..fb1f93a 100755 --- a/scripts/verify-sidecar-signatures.sh +++ b/scripts/verify-sidecar-signatures.sh @@ -2,8 +2,10 @@ # scripts/verify-sidecar-signatures.sh # # Hard-fail check that every Mach-O Argyll sidecar shipped inside the built -# ICCery.app bundle is signed (ad-hoc or Developer ID). Run this in CI after -# xcodebuild and before packaging. +# ICCery.app bundle is signed (ad-hoc or Developer ID). The Argyll tree is +# nested (Vendor/Argyll/macos-universal/… is rsynced into Resources/Argyll by +# the project.yml post-build script), so this scan is recursive — #165 applies +# to binaries at any depth, including mocks/. # # Usage: scripts/verify-sidecar-signatures.sh @@ -26,20 +28,29 @@ if [ ! -d "$SIDECAR_DIR" ]; then exit 1 fi +MACHO_COUNT=0 UNSIGNED="" -for f in "$SIDECAR_DIR"/*; do +while IFS= read -r f; do [ -f "$f" ] || continue - if file -b "$f" | grep -q 'Mach-O'; then - if ! codesign -dvv "$f" >/dev/null 2>&1; then - echo "error: unsigned Mach-O sidecar: $f" >&2 - UNSIGNED="$UNSIGNED $f" - fi + MACHO_COUNT=$((MACHO_COUNT + 1)) + if ! codesign -dvv "$f" >/dev/null 2>&1; then + echo "error: unsigned Mach-O sidecar: $f" >&2 + UNSIGNED="$UNSIGNED $f" fi -done +done <&2 exit 1 fi -echo "OK: all Mach-O sidecars in $SIDECAR_DIR are signed" +if [ "$MACHO_COUNT" -eq 0 ]; then + echo "error: no Mach-O sidecars found under $SIDECAR_DIR" >&2 + exit 1 +fi + +echo "OK: all $MACHO_COUNT Mach-O sidecars under $SIDECAR_DIR are signed"