cat / goq
clone

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

fix/hero-release-note
goq / scripts / package-macos-client.sh
package-macos-client.sh 11.1K 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 # `portal-dist` is generated by src-tauri/build.rs and is not committed, but145 # `cargo tauri build` validates frontendDist before cargo runs any build146 # script. Materialize it first so a clean checkout can build at all.147 cargo check --locked -p portal148 if [[ "$signing_mode" == adhoc ]]; then149 # `-` is the ad-hoc pseudo-identity, not an Apple credential, so it does not150 # violate the guard above. Tauri must apply it *during* bundling: the DMG is151 # built from the .app, so signing afterwards would publish a DMG containing152 # an unsigned bundle that arm64 macOS refuses to execute.153 export APPLE_SIGNING_IDENTITY=-154 fi155 # tauri-cli has no --locked flag; cargo args go after `--`.156 cargo tauri build --ci --target aarch64-apple-darwin --bundles app,dmg -- --locked157)158159bundle_root="$repo_dir/target/aarch64-apple-darwin/release/bundle"160app_path="$bundle_root/macos/Portal.app"161[[ -d "$app_path" ]] || die "Tauri app bundle is missing"162dmg_count="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print | wc -l | tr -d ' ')"163[[ "$dmg_count" -eq 1 ]] || die "expected exactly one DMG artifact"164dmg_path="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print)"165166hdiutil verify "$dmg_path"167codesign --verify --deep --strict --verbose=2 "$app_path"168signature_details="$(codesign -d --verbose=4 "$app_path" 2>&1)"169if [[ "$signing_mode" == developer-id ]]; then170 grep -Fq "Authority=$signing_identity" <<<"$signature_details" \171 || die "application is not signed by the configured Developer ID identity"172 team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<<"$signature_details")"173 [[ "$team_identifier" == "$expected_team_id" ]] \174 || die "application TeamIdentifier does not match the committed Portal signer"175 grep -Eq '^flags=.*runtime' <<<"$signature_details" \176 || die "application signature does not enable hardened runtime"177 spctl --assess --type execute --verbose=4 "$app_path"178 xcrun stapler validate "$app_path"179 xcrun stapler validate "$dmg_path"180 spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path"181else182 # arm64 macOS will not execute an unsigned binary at all, so the ad-hoc183 # signature is a functional requirement rather than a security claim.184 grep -Fq 'Signature=adhoc' <<<"$signature_details" \185 || die "ad-hoc release must carry the mandatory arm64 ad-hoc signature"186 if grep -q '^Authority=' <<<"$signature_details"; then187 die "ad-hoc release unexpectedly carries a certificate authority chain"188 fi189 if grep -q '^TeamIdentifier=[A-Z0-9]' <<<"$signature_details"; then190 die "ad-hoc release unexpectedly carries an Apple TeamIdentifier"191 fi192 if xcrun stapler validate "$dmg_path" >/dev/null 2>&1; then193 die "ad-hoc release unexpectedly carries a notarization ticket"194 fi195fi196197executable="$app_path/Contents/MacOS/portal"198[[ -x "$executable" ]] || die "client executable is missing"199architectures="$(lipo -archs "$executable")"200[[ "$architectures" == arm64 ]] || die "client executable must contain only arm64"201identifier="$(plutil -extract CFBundleIdentifier raw "$app_path/Contents/Info.plist")"202[[ "$identifier" == sh.goq.portal ]] || die "unexpected bundle identifier: $identifier"203if find "$app_path" -type f \( -name sigil -o -name sigil-host -o -name sigil-probe -o -name host.toml -o -name '*.key' \) \204 | grep -q .205then206 die "client bundle contains a forbidden host or credential artifact"207fi208209version="$(plutil -extract CFBundleShortVersionString raw "$app_path/Contents/Info.plist")"210[[ "$release_tag" == "v$version" ]] || die "app version does not match release tag"211git_commit="$(git -C "$repo_dir" rev-parse --verify HEAD)"212artifact_name="Portal-${version}-arm64.dmg"213artifact_path="$output_dir/$artifact_name"214manifest_path="$output_dir/Portal-${version}-arm64.json"215[[ ! -e "$artifact_path" && ! -e "$manifest_path" && ! -e "$artifact_path.sha256" ]] \216 || die "release output already exists"217install -m 0644 "$dmg_path" "$artifact_path"218artifact_sha256="$(shasum -a 256 "$artifact_path" | awk '{print $1}')"219printf '%s %s\n' "$artifact_sha256" "$artifact_name" >"$artifact_path.sha256"220python3 - "$manifest_path" "$release_tag" "$version" "$git_commit" "$artifact_name" "$artifact_sha256" "$signing_mode" <<'PY'221import json222import pathlib223import sys224225path, release_tag, version, git_commit, asset, digest, signing = sys.argv[1:]226developer_id = signing == "developer-id"227manifest = {228 "architecture": "arm64",229 "architectures": ["arm64"],230 "asset": asset,231 "bundle_identifier": "sh.goq.portal",232 "checksum_asset": f"{asset}.sha256",233 "demo_direct_node": False,234 "developer_id_signed": developer_id,235 "format": 2,236 "gatekeeper_verified": developer_id,237 "git_commit": git_commit,238 "hardened_runtime": developer_id,239 "notarized": developer_id,240 "platform": "macos",241 "product": "portal-client",242 "release_tag": release_tag,243 "sha256": digest,244 "signing": signing,245 "stapled": developer_id,246 "version": version,247}248pathlib.Path(path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")249PY250chmod 0644 "$manifest_path"251python3 "$verifier" assets \252 --repo-dir "$repo_dir" \253 --release-tag "$release_tag" \254 --asset-dir "$output_dir" >/dev/null255256printf 'client_package=%s\n' "$artifact_path"257printf 'client_package_sha256=%s\n' "$artifact_sha256"258printf 'client_architectures=%s\n' "$architectures"259printf 'client_release_tag=%s\n' "$release_tag"260printf 'client_git_commit=%s\n' "$git_commit"261if [[ "$signing_mode" == developer-id ]]; then262 printf 'client_signing=developer-id-hardened-runtime\n'263 printf 'client_notarization=stapled-and-validated\n'264else265 printf 'client_signing=adhoc\n'266 printf 'client_notarization=none\n'267fi