Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 82 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<version>-macOS-<arch>.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: |
Expand Down Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions .github/workflows/signing-dry-run.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/ScanStudio/packaging/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
<string>14</string>
<key>ScanStudioRelease</key>
<string></string>
<key>ScanStudioUpdateTeamIdentifier</key>
<string>L95G5TS6AK</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
Expand Down
82 changes: 82 additions & 0 deletions app/ScanStudio/scripts/notarize_artifact.sh
Original file line number Diff line number Diff line change
@@ -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 <path/to/ScanStudio.app>
# notarize_artifact.sh dmg <path/to/ScanStudio-*.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 <app|dmg> <path>}"
artifact="${2:?usage: notarize_artifact.sh <app|dmg> <path>}"
[[ -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"
Loading
Loading