cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/release-build-and-order
goq / scripts / package-macos-client.sh
package-macos-client.sh 10.5K executable
1#!/usr/bin/env bash23set -euo pipefail4umask 07756usage() {7 cat <<'EOF'8Usage: scripts/package-macos-client.sh --release-tag vVERSION9 --output-dir /absolute/path --signing adhoc|developer-id1011Build and verify a distributable macOS DMG. Both modes require:1213 - a clean worktree;14 - a release tag resolving exactly to HEAD and matching every package version;15 - an Apple Silicon runner and the aarch64-apple-darwin Rust target;16 - an arm64-only executable, the pinned bundle identifier, a verified DMG,17 and no host or credential artifact inside the client bundle.1819--signing adhoc is the published release mode. Portal has no Apple Developer20ID, so the DMG is not notarized and Gatekeeper will not accept it silently.21arm64 macOS refuses to execute a wholly unsigned binary, so this mode requires22the linker's ad-hoc signature to be present and rejects any Developer ID23signature that appeared unintentionally. Trust is anchored in GitHub/Sigstore24build provenance and the published SHA-256, not in Apple.2526--signing developer-id additionally requires APPLE_SIGNING_IDENTITY, the27committed team-identifier pin, notarization credentials, hardened runtime,28stapling, and Gatekeeper acceptance. It is preserved for the day an Apple29Developer Program membership exists and is NOT exercised by CI, so re-validate30it end to end before trusting it.3132The normal release is built without optional Cargo features. Development33features, including demo-direct-node and34experimental-non-macos-pointer-capture, are intentionally not accepted.35EOF36}3738die() {39 printf 'macOS package failed: %s\n' "$*" >&240 exit 141}4243script_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)"44repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"45output_dir=""46release_tag=""47signing_mode=""4849while [[ $# -gt 0 ]]; do50 case "$1" in51 --signing)52 [[ $# -ge 2 ]] || die "--signing requires a mode"53 signing_mode="$2"54 shift 255 ;;56 --output-dir)57 [[ $# -ge 2 ]] || die "--output-dir requires a path"58 output_dir="$2"59 shift 260 ;;61 --release-tag)62 [[ $# -ge 2 ]] || die "--release-tag requires a tag"63 release_tag="$2"64 shift 265 ;;66 --help|-h)67 usage68 exit 069 ;;70 *) die "unknown argument: $1" ;;71 esac72done7374[[ "$(uname -s)" == Darwin ]] || die "this package must be built on macOS"75[[ "$(uname -m)" == arm64 ]] || die "the first public Portal release is macOS arm64 only"76[[ -n "$output_dir" && "$output_dir" == /* ]] || die "--output-dir must be absolute"77[[ -n "$release_tag" ]] || die "--release-tag is required"78case "$signing_mode" in79 adhoc|developer-id) ;;80 "") die "--signing adhoc|developer-id is required; there is no default" ;;81 *) die "unknown signing mode: $signing_mode" ;;82esac83[[ -d "$output_dir" ]] || die "output directory does not exist"84output_dir="$(CDPATH='' cd -- "$output_dir" && pwd -P)"85case "$output_dir/" in86 "$repo_dir/"*) die "--output-dir must be outside the source repository" ;;87esac88if find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then89 die "output directory must be empty"90fi9192for command in cargo codesign find git grep hdiutil lipo plutil python3 rustup security sed shasum spctl xcrun; do93 command -v "$command" >/dev/null 2>&1 || die "$command is required"94done9596verifier="$script_dir/verify-portal-release.py"97[[ -f "$verifier" ]] || die "Portal release verifier is missing"98python3 "$verifier" source --repo-dir "$repo_dir" --release-tag "$release_tag" >/dev/null99rustup target list --installed | grep -Fxq aarch64-apple-darwin \100 || die "the aarch64-apple-darwin Rust target is required"101102signing_identity=""103if [[ "$signing_mode" == developer-id ]]; then104 signing_identity="${APPLE_SIGNING_IDENTITY:-}"105 [[ "$signing_identity" == Developer\ ID\ Application:* ]] \106 || die "APPLE_SIGNING_IDENTITY must name a Developer ID Application identity"107 security find-identity -v -p codesigning | grep -Fq "\"$signing_identity\"" \108 || die "APPLE_SIGNING_IDENTITY is not available in the keychain"109 expected_team_id_file="$repo_dir/release/portal-apple-team-id.txt"110 [[ -f "$expected_team_id_file" && ! -L "$expected_team_id_file" ]] \111 || die "committed Portal Apple team identifier is missing or unsafe"112 expected_team_id="$(<"$expected_team_id_file")"113 [[ "$expected_team_id" =~ ^[A-Z0-9]{10}$ ]] \114 || die "release/portal-apple-team-id.txt must contain one Apple TeamIdentifier"115 [[ "${APPLE_TEAM_ID:-}" == "$expected_team_id" ]] \116 || die "APPLE_TEAM_ID does not match the committed Portal signer"117118 api_credentials=false119 apple_id_credentials=false120 if [[ -n "${APPLE_API_ISSUER:-}" && -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_KEY_PATH:-}" ]]; then121 [[ "$APPLE_API_KEY_PATH" == /* && -f "$APPLE_API_KEY_PATH" ]] \122 || die "APPLE_API_KEY_PATH must name an absolute private-key file"123 api_credentials=true124 fi125 if [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then126 apple_id_credentials=true127 fi128 $api_credentials || $apple_id_credentials \129 || die "set complete App Store Connect API or Apple ID notarization credentials"130else131 # Refuse to silently produce a half-signed artifact: in ad-hoc mode Tauri132 # must not find any Apple credential in the environment.133 for leaked in APPLE_SIGNING_IDENTITY APPLE_CERTIFICATE APPLE_API_KEY \134 APPLE_API_ISSUER APPLE_API_KEY_PATH APPLE_ID APPLE_PASSWORD; do135 [[ -z "${!leaked:-}" ]] \136 || die "$leaked is set but --signing adhoc was requested"137 done138fi139140# shellcheck source=/dev/null141source "$HOME/.cargo/env"142(143 cd "$repo_dir"144 # tauri-cli has no --locked flag; cargo args go after `--`.145 cargo tauri build --ci --target aarch64-apple-darwin --bundles app,dmg -- --locked146)147148bundle_root="$repo_dir/target/aarch64-apple-darwin/release/bundle"149app_path="$bundle_root/macos/Portal.app"150[[ -d "$app_path" ]] || die "Tauri app bundle is missing"151dmg_count="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print | wc -l | tr -d ' ')"152[[ "$dmg_count" -eq 1 ]] || die "expected exactly one DMG artifact"153dmg_path="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print)"154155hdiutil verify "$dmg_path"156codesign --verify --deep --strict --verbose=2 "$app_path"157signature_details="$(codesign -d --verbose=4 "$app_path" 2>&1)"158if [[ "$signing_mode" == developer-id ]]; then159 grep -Fq "Authority=$signing_identity" <<<"$signature_details" \160 || die "application is not signed by the configured Developer ID identity"161 team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<<"$signature_details")"162 [[ "$team_identifier" == "$expected_team_id" ]] \163 || die "application TeamIdentifier does not match the committed Portal signer"164 grep -Eq '^flags=.*runtime' <<<"$signature_details" \165 || die "application signature does not enable hardened runtime"166 spctl --assess --type execute --verbose=4 "$app_path"167 xcrun stapler validate "$app_path"168 xcrun stapler validate "$dmg_path"169 spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path"170else171 # arm64 macOS will not execute an unsigned binary at all, so the ad-hoc172 # signature is a functional requirement rather than a security claim.173 grep -Fq 'Signature=adhoc' <<<"$signature_details" \174 || die "ad-hoc release must carry the mandatory arm64 ad-hoc signature"175 if grep -q '^Authority=' <<<"$signature_details"; then176 die "ad-hoc release unexpectedly carries a certificate authority chain"177 fi178 if grep -q '^TeamIdentifier=[A-Z0-9]' <<<"$signature_details"; then179 die "ad-hoc release unexpectedly carries an Apple TeamIdentifier"180 fi181 if xcrun stapler validate "$dmg_path" >/dev/null 2>&1; then182 die "ad-hoc release unexpectedly carries a notarization ticket"183 fi184fi185186executable="$app_path/Contents/MacOS/portal"187[[ -x "$executable" ]] || die "client executable is missing"188architectures="$(lipo -archs "$executable")"189[[ "$architectures" == arm64 ]] || die "client executable must contain only arm64"190identifier="$(plutil -extract CFBundleIdentifier raw "$app_path/Contents/Info.plist")"191[[ "$identifier" == sh.goq.portal ]] || die "unexpected bundle identifier: $identifier"192if find "$app_path" -type f \( -name sigil -o -name sigil-host -o -name sigil-probe -o -name host.toml -o -name '*.key' \) \193 | grep -q .194then195 die "client bundle contains a forbidden host or credential artifact"196fi197198version="$(plutil -extract CFBundleShortVersionString raw "$app_path/Contents/Info.plist")"199[[ "$release_tag" == "v$version" ]] || die "app version does not match release tag"200git_commit="$(git -C "$repo_dir" rev-parse --verify HEAD)"201artifact_name="Portal-${version}-arm64.dmg"202artifact_path="$output_dir/$artifact_name"203manifest_path="$output_dir/Portal-${version}-arm64.json"204[[ ! -e "$artifact_path" && ! -e "$manifest_path" && ! -e "$artifact_path.sha256" ]] \205 || die "release output already exists"206install -m 0644 "$dmg_path" "$artifact_path"207artifact_sha256="$(shasum -a 256 "$artifact_path" | awk '{print $1}')"208printf '%s %s\n' "$artifact_sha256" "$artifact_name" >"$artifact_path.sha256"209python3 - "$manifest_path" "$release_tag" "$version" "$git_commit" "$artifact_name" "$artifact_sha256" "$signing_mode" <<'PY'210import json211import pathlib212import sys213214path, release_tag, version, git_commit, asset, digest, signing = sys.argv[1:]215developer_id = signing == "developer-id"216manifest = {217 "architecture": "arm64",218 "architectures": ["arm64"],219 "asset": asset,220 "bundle_identifier": "sh.goq.portal",221 "checksum_asset": f"{asset}.sha256",222 "demo_direct_node": False,223 "developer_id_signed": developer_id,224 "format": 2,225 "gatekeeper_verified": developer_id,226 "git_commit": git_commit,227 "hardened_runtime": developer_id,228 "notarized": developer_id,229 "platform": "macos",230 "product": "portal-client",231 "release_tag": release_tag,232 "sha256": digest,233 "signing": signing,234 "stapled": developer_id,235 "version": version,236}237pathlib.Path(path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")238PY239chmod 0644 "$manifest_path"240python3 "$verifier" assets \241 --repo-dir "$repo_dir" \242 --release-tag "$release_tag" \243 --asset-dir "$output_dir" >/dev/null244245printf 'client_package=%s\n' "$artifact_path"246printf 'client_package_sha256=%s\n' "$artifact_sha256"247printf 'client_architectures=%s\n' "$architectures"248printf 'client_release_tag=%s\n' "$release_tag"249printf 'client_git_commit=%s\n' "$git_commit"250if [[ "$signing_mode" == developer-id ]]; then251 printf 'client_signing=developer-id-hardened-runtime\n'252 printf 'client_notarization=stapled-and-validated\n'253else254 printf 'client_signing=adhoc\n'255 printf 'client_notarization=none\n'256fi