diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05a536a..0175760 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,9 @@ jobs: release: name: Build and verify ${{ matrix.arch }} DMG + # Bounded because this job now waits on two Apple notarization + # round-trips (app + DMG) on top of the build itself. + timeout-minutes: 150 needs: authorize # A universal bundle is impossible because the pinned cv2/numpy wheels are # not universal, so each architecture is built natively on its own runner @@ -101,14 +104,80 @@ jobs: echo "RELEASE_VERSION=$STAMP" >> "$GITHUB_ENV" echo "SCANSTUDIO_RELEASE_VERSION=$STAMP" >> "$GITHUB_ENV" echo "Resolved release version: $STAMP" + - name: Import the Developer ID signing identity + # A release build must be signed for distribution: this job fails + # loudly when the signing secrets are absent rather than silently + # shipping another ad-hoc build. The certificate lives only in a + # throwaway keychain deleted in the always() cleanup below; the + # identity string (not a secret) is exported for package_app.sh. + env: + P12_B64: ${{ secrets.MACOS_SIGNING_CERT_P12_BASE64 }} + CERT_PW: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }} + run: | + test -n "$P12_B64" || { echo "MACOS_SIGNING_CERT_P12_BASE64 secret is missing or empty"; exit 1; } + test -n "$CERT_PW" || { echo "MACOS_SIGNING_CERT_PASSWORD secret is missing or empty"; exit 1; } + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + KEYCHAIN_PW="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + printf '%s' "$P12_B64" | base64 -d > "$RUNNER_TEMP/signing.p12" + security import "$RUNNER_TEMP/signing.p12" -k "$KEYCHAIN" -P "$CERT_PW" -T /usr/bin/codesign + rm -f "$RUNNER_TEMP/signing.p12" + security set-key-partition-list -S 'apple-tool:,apple:' -s -k "$KEYCHAIN_PW" "$KEYCHAIN" > /dev/null + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk -F'"' '/Developer ID Application/ {print $2; exit}')" + test -n "$IDENTITY" || { echo "no Developer ID Application identity found in the imported certificate"; exit 1; } + echo "SCANSTUDIO_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "Imported signing identity: $IDENTITY" + - name: Write the notarization API key + env: + NOTARY_KEY_P8: ${{ secrets.APPSTORE_CONNECT_API_KEY_P8 }} + run: | + test -n "$NOTARY_KEY_P8" || { echo "APPSTORE_CONNECT_API_KEY_P8 secret is missing or empty"; exit 1; } + NOTARY_KEY_FILE="$RUNNER_TEMP/notary-key.p8" + printf '%s' "$NOTARY_KEY_P8" > "$NOTARY_KEY_FILE" + # The secret is the raw PEM text of the downloaded AuthKey .p8 + # (NOT base64-wrapped, unlike the p12 secret) -- fail here, with a + # clear reason, rather than at the end of a 40-minute build. + grep -q "BEGIN PRIVATE KEY" "$NOTARY_KEY_FILE" || { echo "APPSTORE_CONNECT_API_KEY_P8 does not look like a raw PEM .p8 key"; exit 1; } + echo "NOTARY_KEY_FILE=$NOTARY_KEY_FILE" >> "$GITHUB_ENV" + - name: Build, sign, and verify the app + # package_app.sh signs to notarization requirements with the + # identity exported above; test_packaged_bridge.sh then executes the + # hardened-runtime bundle (bridge smoke on the signed app). + working-directory: app/ScanStudio + run: make package + - name: Notarize and staple the app + # The .app needs its own ticket: the in-app updater's + # publisher-trust gate requires a stapled ticket on the INSTALLED + # app, and an app copied out of a DMG does not inherit the DMG's + # ticket (it also covers offline first launch). + env: + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} + working-directory: app/ScanStudio + run: | + test -n "$NOTARY_KEY_ID" || { echo "APPSTORE_CONNECT_API_KEY_ID secret is missing or empty"; exit 1; } + test -n "$NOTARY_ISSUER_ID" || { echo "APPSTORE_CONNECT_API_ISSUER_ID secret is missing or empty"; exit 1; } + zsh scripts/notarize_artifact.sh app .build/ScanStudio.app - name: Build and verify the local DMG # package_dmg.sh names the artifact ScanStudio--macOS-.dmg # (arch from uname -m, i.e. this runner's native arch) and refuses to - # overwrite an existing artifact. It also verifies the mounted app's - # code signature and runs test_packaged_bridge.sh on it; any failure - # here aborts the job and nothing is released. + # overwrite an existing artifact; it builds from the already + # notarized-and-stapled app above. + working-directory: app/ScanStudio + run: ./scripts/package_dmg.sh + - name: Notarize and staple the DMG + # MUST run before SHA256SUMS/latest.json are emitted: stapling + # mutates the DMG, so every published checksum has to be computed + # from the stapled artifact. + env: + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} working-directory: app/ScanStudio - run: make dmg + run: | + zsh scripts/notarize_artifact.sh dmg ".build/ScanStudio-$RELEASE_VERSION-macOS-$(uname -m).dmg" - name: Emit SHA256SUMS and arch-keyed latest.json working-directory: app/ScanStudio run: | @@ -147,6 +216,15 @@ jobs: app/ScanStudio/.build/latest.json if-no-files-found: error retention-days: 1 + - name: Delete the throwaway signing keychain and key material + if: always() + run: | + rm -f "$RUNNER_TEMP/signing.p12" "$RUNNER_TEMP/notary-key.p8" + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + if [ -f "$KEYCHAIN" ]; then + security list-keychains -d user -s login.keychain-db + security delete-keychain "$KEYCHAIN" + fi windows-resources: name: Assemble Windows offline resources diff --git a/.github/workflows/signing-dry-run.yml b/.github/workflows/signing-dry-run.yml new file mode 100644 index 0000000..5eb09a3 --- /dev/null +++ b/.github/workflows/signing-dry-run.yml @@ -0,0 +1,128 @@ +name: Signing dry run + +# Manually triggered rehearsal of the ENTIRE Developer ID signing + +# notarization + stapling path on one arch, without tagging or publishing +# anything. The release workflow's signing steps only ever run on a real +# tag under fail-fast, so this is the only way to exercise the path -- and +# the runtime checks the notary service cannot make (is the entitlement on +# the right binary, does the hardened interpreter's ctypes dlopen actually +# work under library validation) -- before a release depends on it. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + dry-run: + name: Sign, notarize, staple, and runtime-check (arm64) + runs-on: macos-15 + timeout-minutes: 150 + env: + UV_PYTHON_PREFERENCE: only-managed + UV_PYTHON_CPYTHON_BUILD: "20260718" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable 2026-08-05 + with: + toolchain: '1.97.1' + - name: Install exact uv and managed Python build toolchain + run: | + python3 -I -S -B scripts/install_pinned_uv_python.py \ + --install-root "$RUNNER_TEMP/scanstudio-uv-python" + - name: Verify exact Rust toolchain + run: python3 -I -S -B scripts/verify_pinned_rust.py + - name: Install locked production bridge dependencies + working-directory: bridge + run: uv sync --locked --no-dev --no-install-package python-sane + - name: Import the Developer ID signing identity + env: + P12_B64: ${{ secrets.MACOS_SIGNING_CERT_P12_BASE64 }} + CERT_PW: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }} + run: | + test -n "$P12_B64" || { echo "MACOS_SIGNING_CERT_P12_BASE64 secret is missing or empty"; exit 1; } + test -n "$CERT_PW" || { echo "MACOS_SIGNING_CERT_PASSWORD secret is missing or empty"; exit 1; } + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + KEYCHAIN_PW="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + printf '%s' "$P12_B64" | base64 -d > "$RUNNER_TEMP/signing.p12" + security import "$RUNNER_TEMP/signing.p12" -k "$KEYCHAIN" -P "$CERT_PW" -T /usr/bin/codesign + rm -f "$RUNNER_TEMP/signing.p12" + security set-key-partition-list -S 'apple-tool:,apple:' -s -k "$KEYCHAIN_PW" "$KEYCHAIN" > /dev/null + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk -F'"' '/Developer ID Application/ {print $2; exit}')" + test -n "$IDENTITY" || { echo "no Developer ID Application identity found in the imported certificate"; exit 1; } + echo "SCANSTUDIO_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "Imported signing identity: $IDENTITY" + - name: Write the notarization API key + env: + NOTARY_KEY_P8: ${{ secrets.APPSTORE_CONNECT_API_KEY_P8 }} + run: | + test -n "$NOTARY_KEY_P8" || { echo "APPSTORE_CONNECT_API_KEY_P8 secret is missing or empty"; exit 1; } + NOTARY_KEY_FILE="$RUNNER_TEMP/notary-key.p8" + printf '%s' "$NOTARY_KEY_P8" > "$NOTARY_KEY_FILE" + grep -q "BEGIN PRIVATE KEY" "$NOTARY_KEY_FILE" || { echo "APPSTORE_CONNECT_API_KEY_P8 does not look like a raw PEM .p8 key"; exit 1; } + echo "NOTARY_KEY_FILE=$NOTARY_KEY_FILE" >> "$GITHUB_ENV" + - name: Build, sign, and verify the app + working-directory: app/ScanStudio + env: + SCANSTUDIO_RELEASE_VERSION: 0.0.0-signing-dry-run + run: make package + - name: Assert the entitlement landed on the bridge interpreter only + working-directory: app/ScanStudio + run: | + PYBIN="$(/usr/bin/find .build/ScanStudio.app/Contents/Resources -type f -name 'python3.13' -path '*/bin/*' | head -1)" + test -n "$PYBIN" || { echo "bridge interpreter not found in the bundle"; exit 1; } + codesign -d --entitlements - "$PYBIN" 2>/dev/null | grep -q "disable-library-validation" \ + || { echo "bridge interpreter is missing the disable-library-validation entitlement"; exit 1; } + codesign -d --entitlements - .build/ScanStudio.app/Contents/MacOS/scanstudio-engine 2>/dev/null | grep -q "disable-library-validation" \ + && { echo "engine unexpectedly carries the entitlement"; exit 1; } + echo "entitlement scoped correctly" + - name: Notarize and staple the app + env: + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} + working-directory: app/ScanStudio + run: | + test -n "$NOTARY_KEY_ID" || { echo "APPSTORE_CONNECT_API_KEY_ID secret is missing or empty"; exit 1; } + test -n "$NOTARY_ISSUER_ID" || { echo "APPSTORE_CONNECT_API_ISSUER_ID secret is missing or empty"; exit 1; } + zsh scripts/notarize_artifact.sh app .build/ScanStudio.app + - name: Prove the hardened interpreter can dlopen the bundled libusb + # The check the notary service cannot make: under hardened runtime + + # library validation, the entitled interpreter must still be able to + # ctypes-load the bundled dylib (the live capture path's first step). + working-directory: app/ScanStudio + run: | + PYBIN="$(/usr/bin/find .build/ScanStudio.app/Contents/Resources -type f -name 'python3.13' -path '*/bin/*' | head -1)" + "$PYBIN" -I -c "import ctypes; ctypes.CDLL('.build/ScanStudio.app/Contents/Frameworks/coolscanpy/_native/libusb-1.0.dylib'); print('dlopen ok under hardened runtime')" + - name: Build the DMG from the stapled app + working-directory: app/ScanStudio + run: ./scripts/package_dmg.sh + - name: Notarize and staple the DMG + env: + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} + working-directory: app/ScanStudio + run: | + zsh scripts/notarize_artifact.sh dmg .build/ScanStudio-*-macOS-$(uname -m).dmg + - name: Upload the dry-run DMG for manual inspection + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: signing-dry-run-dmg + path: app/ScanStudio/.build/ScanStudio-*-macOS-*.dmg + if-no-files-found: error + retention-days: 3 + - name: Delete the throwaway signing keychain and key material + if: always() + run: | + rm -f "$RUNNER_TEMP/signing.p12" "$RUNNER_TEMP/notary-key.p8" + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + if [ -f "$KEYCHAIN" ]; then + security list-keychains -d user -s login.keychain-db + security delete-keychain "$KEYCHAIN" + fi diff --git a/app/ScanStudio/packaging/Info.plist b/app/ScanStudio/packaging/Info.plist index b3d3c0d..51594d7 100644 --- a/app/ScanStudio/packaging/Info.plist +++ b/app/ScanStudio/packaging/Info.plist @@ -20,6 +20,8 @@ 14 ScanStudioRelease + ScanStudioUpdateTeamIdentifier + L95G5TS6AK LSMinimumSystemVersion 14.0 NSHighResolutionCapable diff --git a/app/ScanStudio/scripts/notarize_artifact.sh b/app/ScanStudio/scripts/notarize_artifact.sh new file mode 100755 index 0000000..a22086b --- /dev/null +++ b/app/ScanStudio/scripts/notarize_artifact.sh @@ -0,0 +1,82 @@ +#!/bin/zsh +set -euo pipefail + +# Notarizes and staples ONE release artifact -- the .app first, then the +# .dmg built from the stapled app. Both need their own ticket: the in-app +# updater's publisher-trust gate requires a stapled ticket on the INSTALLED +# app (UpdateService reads kSecCodeInfoStapledNotarizationTicket), and an +# app copied out of a DMG does not inherit the DMG's ticket; the DMG's own +# ticket covers the first-open Gatekeeper check of the download itself, +# including offline. +# +# Runs only in release automation, and for the DMG strictly BEFORE +# SHA256SUMS/latest.json are emitted -- stapling mutates the file, so every +# published checksum must be computed after this script succeeds. +# +# Usage: notarize_artifact.sh app +# notarize_artifact.sh dmg +# +# Required environment: +# SCANSTUDIO_SIGNING_IDENTITY Developer ID Application identity string +# NOTARY_KEY_FILE path to the App Store Connect API .p8 key +# NOTARY_KEY_ID the key id for that key +# NOTARY_ISSUER_ID the issuer id for the team + +kind="${1:?usage: notarize_artifact.sh }" +artifact="${2:?usage: notarize_artifact.sh }" +[[ -e "$artifact" ]] || { print -u2 "no such artifact: $artifact"; exit 1; } +: "${SCANSTUDIO_SIGNING_IDENTITY:?}" "${NOTARY_KEY_FILE:?}" "${NOTARY_KEY_ID:?}" "${NOTARY_ISSUER_ID:?}" + +submit_and_require_accepted() { + local upload="$1" + local submission submission_id + # `|| true`: notarytool's exit code differs across Xcode versions and a + # transport/auth failure must still leave its output in the log -- the + # status line in the captured output is the single authority either way. + submission="$(xcrun notarytool submit "$upload" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" \ + --wait --timeout 45m 2>&1)" || true + print -r -- "$submission" + if [[ "$submission" != *"status: Accepted"* ]]; then + submission_id="$(print -r -- "$submission" | awk '/^[[:space:]]*id: /{print $2; exit}')" + if [[ -n "$submission_id" ]]; then + # The per-file rejection reasons live only in this log. + xcrun notarytool log "$submission_id" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" || true + fi + print -u2 "notarization was not accepted for $upload" + exit 1 + fi +} + +case "$kind" in + app) + # Apps are submitted as a zip; the ticket staples onto the bundle. + upload_dir="$(mktemp -d)" + trap 'rm -rf "$upload_dir"' EXIT + upload_zip="$upload_dir/${artifact:t}.zip" + ditto -c -k --keepParent "$artifact" "$upload_zip" + submit_and_require_accepted "$upload_zip" + xcrun stapler staple "$artifact" + xcrun stapler validate "$artifact" + ;; + dmg) + codesign --force --sign "$SCANSTUDIO_SIGNING_IDENTITY" --timestamp "$artifact" + submit_and_require_accepted "$artifact" + xcrun stapler staple "$artifact" + xcrun stapler validate "$artifact" + # Gatekeeper's own verdict on the stapled DMG -- the check a user's + # machine effectively runs on first open. + spctl -a -t open --context context:primary-signature -v "$artifact" + ;; + *) + print -u2 "unknown artifact kind: $kind (expected app or dmg)" + exit 1 + ;; +esac + +print "Notarized and stapled $artifact" diff --git a/app/ScanStudio/scripts/package_app.sh b/app/ScanStudio/scripts/package_app.sh index 64d8d66..f26820a 100755 --- a/app/ScanStudio/scripts/package_app.sh +++ b/app/ScanStudio/scripts/package_app.sh @@ -628,11 +628,63 @@ fi # before signing so the distributed binary contains no builder filesystem # paths. This does not alter executable code. strip -S "$staged_app/Contents/MacOS/scanstudio-engine" "$staged_app/Contents/MacOS/ScanStudio" -codesign --force --sign - "$bundled_libusb" + +# Signing identity: "-" (the default) keeps the ad-hoc signature every local +# and PR-CI build has always produced. The release workflow exports the +# Developer ID Application identity it imported into a throwaway keychain, +# which switches this block to notarization-grade signing: every Mach-O in +# the bundle signed individually with a secure timestamp (notarytool rejects +# any unsigned Mach-O, and --deep never descends into Resources, where the +# whole BridgeRuntime CPython tree lives), hardened runtime on every +# executable, and executables carrying disable-library-validation. That +# entitlement exists for the HOST libraries the bridge's optional paths +# ctypes-load -- a Homebrew libsane found via find_library is signed by a +# foreign team, exactly what library validation blocks. (The bundled libusb +# is same-team-signed and would pass validation on its own.) +signing_identity="${SCANSTUDIO_SIGNING_IDENTITY:--}" +timestamp_flags=() +runtime_flags=() +if [[ "$signing_identity" != "-" ]]; then + timestamp_flags=(--timestamp) + runtime_flags=(--options runtime) + bridge_entitlements="$staging_root/bridge-runtime.entitlements" + cat > "$bridge_entitlements" <<'PLIST' + + + + + com.apple.security.cs.disable-library-validation + + + +PLIST + while IFS= read -r -d '' candidate; do + candidate_type="$(file -b "$candidate")" + [[ "$candidate_type" == *Mach-O* ]] || continue + if [[ "$candidate_type" == *executable* ]]; then + codesign --force --sign "$signing_identity" --timestamp --options runtime \ + --entitlements "$bridge_entitlements" "$candidate" + else + codesign --force --sign "$signing_identity" --timestamp "$candidate" + fi + done < <(find "$staged_app/Contents/Resources" "$staged_app/Contents/Frameworks" \ + -type f \( -perm -u+x -o -name '*.so' -o -name '*.dylib' \) -print0 2>/dev/null) + # The predicate above is a fork-saving pre-filter only (this tree holds + # thousands of plain-text files); file(1) remains the authority on what + # is actually a Mach-O. Every Mach-O in the uv-managed CPython layout is + # executable-bit or *.so/*.dylib. Note for provenance audits: this sweep + # re-signs files whose hashes were pinned earlier in this script (e.g. + # the python-sane extension), so release artifacts intentionally differ + # from those pre-signing pins. +fi +codesign --force --sign "$signing_identity" "${timestamp_flags[@]}" "$bundled_libusb" codesign --verify --strict "$bundled_libusb" -codesign --force --sign - "$staged_app/Contents/MacOS/scanstudio-engine" -codesign --force --sign - "$staged_app/Contents/MacOS/ScanStudio" -codesign --force --deep --sign - "$staged_app" +codesign --force --sign "$signing_identity" "${timestamp_flags[@]}" "${runtime_flags[@]}" \ + "$staged_app/Contents/MacOS/scanstudio-engine" +codesign --force --sign "$signing_identity" "${timestamp_flags[@]}" "${runtime_flags[@]}" \ + "$staged_app/Contents/MacOS/ScanStudio" +codesign --force --deep --sign "$signing_identity" "${timestamp_flags[@]}" "${runtime_flags[@]}" \ + "$staged_app" codesign --verify --deep --strict "$staged_app" replacement_started=1 diff --git a/app/ScanStudio/scripts/package_dmg.sh b/app/ScanStudio/scripts/package_dmg.sh index 5f86e9f..070c1e8 100755 --- a/app/ScanStudio/scripts/package_dmg.sh +++ b/app/ScanStudio/scripts/package_dmg.sh @@ -83,4 +83,7 @@ mounted=0 mv "$temporary_dmg" "$output" digest="$(shasum -a 256 "$output" | awk '{print $1}')" print "Packaged $output" -print "SHA-256 $digest" +# Local digest of the freshly built DMG. In the release pipeline +# notarization stapling mutates the file AFTER this point, so the published +# SHA256SUMS value will differ from this line -- it is informational only. +print "SHA-256 (pre-staple) $digest" diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index be271a1..4341674 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -9,11 +9,14 @@ ## Why two paths -ScanStudio is ad-hoc signed today (`codesign -dv` → `Signature=adhoc`, no -TeamIdentifier). Neither the custom updater nor Sparkle may install a release -from that trust state. A checksum supplied by the release server is an -integrity check, not an independent publisher identity. Path A can check and -download today, but installation fails closed until the running app and update +Release builds are Developer-ID signed, notarized, and stapled as of the +signing lane (release.yml's DMG job signs the app and DMG, notarizes both, +and staples both; local/PR-CI builds stay ad-hoc — `codesign -dv` → +`Signature=adhoc`). The packaged Info.plist stamps +`ScanStudioUpdateTeamIdentifier`. A checksum supplied by the release server +is an integrity check, not an independent publisher identity. Path A can +check and download, and with the signing lane in place the running app and +update are Developer ID signed, securely timestamped, notarized, and stapled. ## Path A — how it works and its publisher gate @@ -108,12 +111,13 @@ imported into your keychain. the feed's base data (see reuse map below). 5. **Decide and document repo-secret names before wiring CI** — settle the secret surface only after the cert + notary + key all exist. - **Why:** CI wiring is the last step and should reference already-decided - names (`DEVELOPER_ID_CERT`, `DEVELOPER_ID_TEAM`, `NOTARY_API_KEY`, - `NOTARY_KEY_ID`, `NOTARY_KEY_ISSUER_ID`, `SPARKLE_EDDSA_KEY`, …) so the - appcast step is a one-shot edit to `release.yml`. **Where:** repository - Settings → Secrets and variables → Actions; record the chosen names in the - future Sparkle plan. + **Why:** CI wiring should reference already-decided names. DECIDED and + live in the signing lane: `MACOS_SIGNING_CERT_P12_BASE64`, + `MACOS_SIGNING_CERT_PASSWORD`, `APPSTORE_CONNECT_API_KEY_P8` (raw PEM, + not base64), `APPSTORE_CONNECT_API_KEY_ID`, + `APPSTORE_CONNECT_API_ISSUER_ID`. A Sparkle path would add only + `SPARKLE_EDDSA_KEY`. **Where:** repository Settings → Secrets and + variables → Actions. ## Reuse map @@ -127,9 +131,10 @@ What Path B reuses from Path A, and what Path B adds. | `UpdateDownloader` (01-04, planned) | Already validates hash + code signature; can gate what Sparkle is offered as a candidate | | `UpdateSettingsView` / `UpdateFlowModel` (01-05, planned) | Unchanged — they keep presenting version/install/rollback, just fed by Sparkle instead of Path A | -**What Sparkle ADDS (none exist today):** the `SUFeedURL` key in the packaged -`Info.plist`, EdDSA feed signatures, Developer ID signing, notarization + -staple, and an appcast publish step in CI. +**What Sparkle ADDS (beyond the signing lane, which already provides +Developer ID signing, notarization, and stapling on releases):** the +`SUFeedURL` key in the packaged `Info.plist`, EdDSA feed signatures, and an +appcast publish step in CI. **What would change when the gate opens:** `app/ScanStudio/packaging/Info.plist` (SUFeedURL), `app/ScanStudio/scripts/package_app.sh` (sign `-` → Developer ID