From 23d04591d4a2820c3ed2756059aff99a0d0ccd82 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sun, 30 Aug 2026 19:41:21 -0400 Subject: [PATCH 1/3] ci: add the build/release and App Store pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the two pipelines that lived on the fork into this repo, and gates publishing behind an explicit opt-in. release.yml is manual-dispatch with a `publish_release` checkbox: unchecked (default) — build every artifact on every platform, verify the release contents, render the downloads table into the run summary, and stop. No tag, no release. checked — the same build, then tag v (UTC) at the built commit and publish the release with the same body/layout as before. The version job now also emits the tag and the resolved commit SHA, so a published tag lands on what was actually built rather than on the ref the run was dispatched from (they differ whenever build_branch is set). A pre-flight check refuses to publish onto an existing tag instead of letting the release action move it. appstore.yml is unchanged and stays manual-only. sync-upstream.yml is deliberately not brought over — it syncs a fork from upstream and has no meaning here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vw17TVNLNCrcnmYUngVPkq --- .github/workflows/appstore.yml | 611 +++++++++++++++++++ .github/workflows/release.yml | 1045 ++++++++++++++++++++++++++++++++ 2 files changed, 1656 insertions(+) create mode 100644 .github/workflows/appstore.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/appstore.yml b/.github/workflows/appstore.yml new file mode 100644 index 0000000..aa2dd26 --- /dev/null +++ b/.github/workflows/appstore.yml @@ -0,0 +1,611 @@ +# Mac App Store build and submission pipeline for IRIS. +# +# Manual trigger only — run this when you're ready to submit a new build, +# not on every commit. +# +# Required repository secrets: +# APP_STORE_APP_CERT base64-encoded Mac App Distribution .p12 +# APP_STORE_APP_CERT_PWD password for the above +# APP_STORE_INSTALLER_CERT base64-encoded Mac Installer Distribution .p12 +# APP_STORE_INSTALLER_CERT_PWD +# APP_STORE_PROVISIONING_PROFILE base64-encoded .provisionprofile +# APP_STORE_CONNECT_KEY_ID API key ID from App Store Connect +# APP_STORE_CONNECT_ISSUER_ID issuer ID from App Store Connect +# APP_STORE_CONNECT_PRIVATE_KEY base64-encoded .p8 private key +# +# One-time setup on developer.apple.com before first run: +# 1. Create an App ID for io.github.danifunker.iris with these capabilities: +# - App Sandbox +# - Increased Memory Limit (for JIT — labelled "com.apple.security.cs.allow-jit") +# - Camera +# - Network (client + server) +# 2. Create a Mac App Distribution provisioning profile for that App ID +# 3. Create a Mac App Distribution certificate + Mac Installer Distribution certificate +# 4. Create an app record in App Store Connect (name: IRIS, bundle ID: io.github.danifunker.iris) +# 5. Generate an App Store Connect API key (Users and Access → Integrations → App Store Connect API) + +name: App Store + +on: + workflow_dispatch: + inputs: + build_branch: + description: Branch to build from (leave empty for workflow branch) + required: false + default: '' + validate_only: + description: Validate package but do not submit to App Store Connect + required: false + type: boolean + default: false + preflight_only: + description: Run ONLY the preflight (skip builds + package) — fast ~1 min secret/auth check + required: false + type: boolean + default: false + runner_image: + description: Runner image for the preflight, build and package jobs (default macos-26 — pinned for determinism; override with macos-latest / macos-15 if needed). + required: false + default: macos-26 + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + BUNDLE_ID: io.github.danifunker.iris + +jobs: + # ── Preflight: validate secrets/certs BEFORE the ~15 min of builds ─────────── + # Imports both distribution certs into a throwaway keychain and proves each + # one is a usable *identity* (cert + private key), checks the provisioning + # profile parses, and checks the App Store Connect API key — failing in ~1 min + # instead of after the arm64/x64 builds if a secret is wrong. + + preflight: + name: Preflight (validate App Store secrets) + runs-on: ${{ github.event.inputs.runner_image || 'macos-26' }} + outputs: + version: ${{ steps.version.outputs.version }} + bundle_version: ${{ steps.version.outputs.bundle_version }} + steps: + # Stamp the version here, in the first job, so the arm64/x64 build jobs can + # bake it into the binary (RELEASE_VERSION → APP_VERSION, shown in About) + # AND the packaging job writes the *same* value into Info.plist's + # CFBundleVersion. Generating it in the later package job left the builds + # with no RELEASE_VERSION, so About fell back to CARGO_PKG_VERSION (0.1.0). + - name: Generate version + id: version + run: | + VERSION=$(date -u +"%Y-%m-%d-%H-%M") + # App Store requires numeric integers separated by dots (e.g. 20250609.0200) + BUNDLE_VERSION=$(echo "$VERSION" | sed 's/\([0-9]*\)-\([0-9]*\)-\([0-9]*\)-\([0-9]*\)-\([0-9]*\)/\1\2\3.\4\5/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT + - name: Verify certificates, provisioning profile, and API key + env: + APP_STORE_APP_CERT: ${{ secrets.APP_STORE_APP_CERT }} + APP_STORE_APP_CERT_PWD: ${{ secrets.APP_STORE_APP_CERT_PWD }} + APP_STORE_INSTALLER_CERT: ${{ secrets.APP_STORE_INSTALLER_CERT }} + APP_STORE_INSTALLER_CERT_PWD: ${{ secrets.APP_STORE_INSTALLER_CERT_PWD }} + APP_STORE_PROVISIONING_PROFILE: ${{ secrets.APP_STORE_PROVISIONING_PROFILE }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + run: | + fail=0 + K="$RUNNER_TEMP/preflight.keychain-db" + KP=$(openssl rand -base64 24) + security create-keychain -p "$KP" "$K" + security set-keychain-settings -lut 3600 "$K" + security unlock-keychain -p "$KP" "$K" + + # Decode + import a base64 .p12. rc: 0/1 imported, 2 empty, 3 bad base64. + import_p12() { + local b64="$1" pwd="$2" + [ -z "$b64" ] && return 2 + local f="$RUNNER_TEMP/pf_$RANDOM.p12" + echo "$b64" | base64 --decode > "$f" 2>/dev/null || { rm -f "$f"; return 3; } + [ -s "$f" ] || { rm -f "$f"; return 3; } + security import "$f" -P "$pwd" -A -t cert -f pkcs12 -k "$K" >/dev/null 2>&1 + local rc=$?; rm -f "$f"; return $rc + } + + import_p12 "$APP_STORE_APP_CERT" "$APP_STORE_APP_CERT_PWD" + case $? in 2) echo "::error::APP_STORE_APP_CERT is empty"; fail=1;; 3) echo "::error::APP_STORE_APP_CERT is not valid base64"; fail=1;; esac + + import_p12 "$APP_STORE_INSTALLER_CERT" "$APP_STORE_INSTALLER_CERT_PWD" + case $? in 2) echo "::error::APP_STORE_INSTALLER_CERT is empty"; fail=1;; 3) echo "::error::APP_STORE_INSTALLER_CERT is not valid base64"; fail=1;; esac + + security set-key-partition-list -S apple-tool:,apple: -k "$KP" "$K" >/dev/null 2>&1 || true + + echo "── Identities (certificate + private key) in keychain ──" + security find-identity "$K" || true + echo "────────────────────────────────────────────────────────" + + # Distinguish "usable identity" vs "cert without key" vs "absent". + check_identity() { # $1 pattern $2 human name $3 secret name + if security find-identity "$K" | grep -q "$1"; then + echo "✅ $2: usable identity (certificate + private key present)." + elif security find-certificate -a -c "$1" "$K" >/dev/null 2>&1; then + echo "::error::$2: the certificate is present but has NO private key. $3 must be the *identity* exported from Keychain Access — expand the certificate's disclosure triangle, select the cert WITH the private key under it, and Export as .p12. A plain .cer has no key." + fail=1 + else + echo "::error::$2: certificate not found in the keychain at all — wrong .p12 in $3, wrong ${3}_PWD, or bad base64." + fail=1 + fi + } + check_identity "3rd Party Mac Developer Application" "Application (app signing)" "APP_STORE_APP_CERT" + check_identity "3rd Party Mac Developer Installer" "Installer (productbuild)" "APP_STORE_INSTALLER_CERT" + + # SHA-1 of the cert we sign the .app with — must appear in the profile. + APP_SHA=$(security find-identity "$K" | grep "3rd Party Mac Developer Application" | head -1 | awk '{print $2}') + security delete-keychain "$K" >/dev/null 2>&1 || true + + PP="$RUNNER_TEMP/pf.provisionprofile" + echo "$APP_STORE_PROVISIONING_PROFILE" | base64 --decode > "$PP" 2>/dev/null || true + if [ ! -s "$PP" ]; then + echo "::error::APP_STORE_PROVISIONING_PROFILE is empty or not valid base64." + fail=1 + else + sz=$(wc -c < "$PP" | tr -d ' ') + echo "Provisioning profile: ${sz} bytes; first bytes: $(xxd -l 8 -p "$PP" 2>/dev/null)" + # The build only base64-decodes + embeds the profile (no parsing), so + # accept it if it decodes to a real provisioning profile. Prefer the + # signed-CMS decode; fall back to grepping the embedded plist (the + # profile's plist is plaintext inside the CMS envelope). + if PLIST=$(security cms -D -i "$PP" 2>/dev/null) && [ -n "$PLIST" ]; then + NAME=$(printf '%s' "$PLIST" | plutil -extract Name raw - 2>/dev/null || echo "?") + echo "✅ Provisioning profile parses (Name: ${NAME})." + # The .app's executable must be signed with a cert that is embedded + # in the profile (App Store rejects a mismatch). Compare SHA-1s. + printf '%s' "$PLIST" > "$RUNNER_TEMP/pp.plist" + PROF_SHAS=$(python3 -c "import plistlib,hashlib,sys; d=plistlib.load(open(sys.argv[1],'rb')); print(chr(10).join(hashlib.sha1(bytes(c)).hexdigest().upper() for c in d.get('DeveloperCertificates',[])))" "$RUNNER_TEMP/pp.plist") + echo "Profile certificate SHA-1(s): $(echo "$PROF_SHAS" | tr '\n' ' ')" + if [ -n "$APP_SHA" ] && echo "$PROF_SHAS" | grep -qi "$APP_SHA"; then + echo "✅ Signing certificate ($APP_SHA) is present in the provisioning profile." + else + echo "::error::The provisioning profile does NOT contain your app-signing certificate (3rd Party Mac Developer Application, ${APP_SHA}). The profile references a different cert (likely 'Apple Distribution'). Regenerate the Mac App Store profile at developer.apple.com selecting the '3rd Party Mac Developer Application' certificate, then re-encode it into APP_STORE_PROVISIONING_PROFILE." + fail=1 + fi + rm -f "$RUNNER_TEMP/pp.plist" + elif grep -qa "ProvisionedDevices\|AppIDName\|TeamIdentifier\|com.apple.application-identifier" "$PP"; then + echo "✅ Provisioning profile decodes and contains profile markers." + else + echo "::error::APP_STORE_PROVISIONING_PROFILE decoded to ${sz} bytes but is not a provisioning profile (no profile markers). Likely the wrong file, a base64-of-base64, or a .mobileprovision saved as text. Download the Mac App Store provisioning profile (.provisionprofile) for App ID ${BUNDLE_ID} from developer.apple.com and re-encode it with: base64 -i profile.provisionprofile | pbcopy" + fail=1 + fi + fi + rm -f "$PP" + + [ -n "$APP_STORE_CONNECT_KEY_ID" ] && echo "✅ APP_STORE_CONNECT_KEY_ID set" || { echo "::error::APP_STORE_CONNECT_KEY_ID is empty"; fail=1; } + [ -n "$APP_STORE_CONNECT_ISSUER_ID" ] && echo "✅ APP_STORE_CONNECT_ISSUER_ID set" || { echo "::error::APP_STORE_CONNECT_ISSUER_ID is empty"; fail=1; } + if echo "$APP_STORE_CONNECT_PRIVATE_KEY" | base64 --decode 2>/dev/null | grep -q "PRIVATE KEY"; then + echo "✅ APP_STORE_CONNECT_PRIVATE_KEY is a .p8 private key" + # Smoke-test the API key end to end the way Apple intends now: mint an + # ES256 JWT from the .p8 and call the App Store Connect API. Xcode 26's + # altool (what macos-latest resolves to since the macOS 26 rollout) + # dropped API-key auth from `altool --list-providers` — username/ + # password only there now — so the old check failed on every macOS 26 + # run even with a valid key. --generate-jwt + GET /v1/apps is the same + # auth the upload uses and is independent of altool subcommand changes; + # a bad key/issuer/role fails here in ~1 min with the real HTTP status. + if [ -n "$APP_STORE_CONNECT_KEY_ID" ] && [ -n "$APP_STORE_CONNECT_ISSUER_ID" ]; then + mkdir -p "$HOME/.appstoreconnect/private_keys" + KEYF="$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" + echo "$APP_STORE_CONNECT_PRIVATE_KEY" | base64 --decode > "$KEYF" + # Mint the ES256 App Store Connect JWT with openssl + python stdlib + # only — NOT altool. GitHub's macos-latest is mid-migration and lands + # unpredictably on macOS 15 (altool 8.303) or macOS 26 (altool 26.x), + # whose altool builds disagree on JWT / --list-providers support, so + # any altool-based check is a coin flip. openssl+python are identical + # on every runner image. python parses openssl's DER ECDSA signature + # into the JOSE raw r||s (64 bytes) the JWT spec wants. + NOW=$(date +%s) + b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } + HDR=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$APP_STORE_CONNECT_KEY_ID" | b64url) + PL=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$APP_STORE_CONNECT_ISSUER_ID" "$NOW" "$((NOW + 600))" | b64url) + SI="${HDR}.${PL}" + SIG=$(printf '%s' "$SI" | openssl dgst -sha256 -sign "$KEYF" \ + | python3 -c 'import sys,base64; der=sys.stdin.buffer.read(); i=2 if der[1]<0x80 else 2+(der[1]&0x7f); rl=der[i+1]; r=der[i+2:i+2+rl]; j=i+2+rl; sl=der[j+1]; s=der[j+2:j+2+sl]; print(base64.urlsafe_b64encode(r.lstrip(b"\x00").rjust(32,b"\x00")+s.lstrip(b"\x00").rjust(32,b"\x00")).decode().rstrip("="))') + JWT="${SI}.${SIG}" + if [ -z "$SIG" ]; then + echo "::error::Could not sign the App Store Connect JWT — the .p8 in APP_STORE_CONNECT_PRIVATE_KEY may be malformed (openssl/python signing failed above)." + fail=1 + else + code=$(curl -sS -o "$RUNNER_TEMP/asc.json" -w '%{http_code}' \ + -H "Authorization: Bearer $JWT" \ + "https://api.appstoreconnect.apple.com/v1/apps?limit=1") + case "$code" in + 200) echo "✅ App Store Connect API key authenticates." ;; + 401) echo "::error::App Store Connect rejected the key (401) — APP_STORE_CONNECT_KEY_ID / APP_STORE_CONNECT_ISSUER_ID / .p8 don't match, or the key was revoked."; fail=1 ;; + 403) echo "::error::Key authenticates but isn't authorized (403) — it needs the App Manager (or Admin) role to upload builds."; fail=1 ;; + *) echo "::error::App Store Connect auth check returned HTTP $code: $(cat "$RUNNER_TEMP/asc.json" 2>/dev/null)"; fail=1 ;; + esac + fi + rm -f "$KEYF" + fi + else echo "::error::APP_STORE_CONNECT_PRIVATE_KEY is not a base64-encoded .p8 private key."; fail=1; fi + + if [ "$fail" != "0" ]; then + echo ""; echo "Preflight FAILED — fix the secrets above; the builds were skipped to save time."; exit 1 + fi + echo ""; echo "Preflight passed ✅ — certs, provisioning profile, and API key all look good." + + # ── Build (both arches on ONE arm64 runner; x86_64 cross-compiled) ─────────── + # macOS ships a universal SDK, so an Apple-Silicon runner builds + # x86_64-apple-darwin natively — no separate Intel runner (which was flaky on + # actions/cache and is EOL in 2027 anyway). One job emits both slices; the + # package job lipos them into a universal binary exactly as before. + + build: + name: Build (arm64 + x86_64 cross) + needs: preflight + if: ${{ github.event.inputs.preflight_only != 'true' }} + runs-on: ${{ github.event.inputs.runner_image || 'macos-26' }} + timeout-minutes: 45 # a hung actions/cache step can't sit for the 6h default + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.build_branch || 'main' }} + + - name: Add both macOS targets to the pinned (nightly) toolchain + run: | + # rust-toolchain.toml pins nightly, so cargo uses THAT — not stable. + # `rustup show` (run in the checked-out repo) auto-installs the pinned + # toolchain; then add both macOS targets to it. The host arm64 std is + # already present, but x86_64 must be added or the cross build can't + # find core/std (adding it to stable via dtolnay wouldn't help — the + # build uses nightly). Mirrors the riscv64 job in release.yml. + rustup show + rustup target add aarch64-apple-darwin x86_64-apple-darwin + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: macos-universal-appstore-cargo-${{ hashFiles('**/Cargo.lock') }} + + # ── Guideline 2.5.1, part 1: the winit patch must actually apply ───────── + # winit's macOS backend calls the private SkyLight APIs + # `_CGSSetWindowBackgroundBlurRadius` / `_CGSMainConnectionID` from + # `WindowDelegate::set_blur`. IRIS never requests blur, but the import + # lands in the binary anyway and Apple's static scan rejects it — that is + # what killed submission 2ed07ab1. `third_party/winit-0.30.13/` is a + # vendored copy with the call stubbed out, wired in via `[patch.crates-io]`. + # + # The dangerous failure mode is silent: a patch that matches nothing makes + # cargo print "patch ... was not used in the crate graph" and fall back to + # the UNPATCHED registry crate — the build still succeeds. So assert the + # patched (path-sourced, i.e. no `source =` line) winit is really in the + # resolved graph. Cargo.lock is gitignored, so resolve it here first + # instead of reading a file a fresh checkout doesn't have. + # + # A registry winit ALONGSIDE the patched one is only a warning: the patch + # matches the 0.30.x requirement only, so `iris`'s own winit 0.29 (used for + # the KeyCode type) stays unpatched. It has historically been dead-stripped + # — but it is exactly the copy that regressed on 2026-08-03, so it is worth + # naming. The nm gate after the build is the authoritative check. + # Detail: rules/macos/appstore-private-api.md + - name: Resolve the graph and verify the winit patch applied + run: | + cargo fetch + winits=$(awk ' + /^\[\[package\]\]/ { name=""; ver=""; src="patched" } + /^name = / { gsub(/"/,""); name=$3 } + /^version = / { gsub(/"/,""); ver=$3 } + /^source = / { src="registry" } + /^$/ { if (name=="winit") { print ver, src; name="" } } + END { if (name=="winit") print ver, src } + ' Cargo.lock) + echo "winit in the resolved graph:" + printf '%s\n' "$winits" | sed 's/^/ /' + if ! printf '%s\n' "$winits" | grep -q ' patched$'; then + echo "::error::No path-patched winit in the resolved graph — [patch.crates-io] winit = { path = \"third_party/winit-0.30.13\" } did not apply, so this build links the UNPATCHED winit and imports the private SkyLight API. App Store review rejects that under guideline 2.5.1. See rules/macos/appstore-private-api.md." + exit 1 + fi + unpatched=$(printf '%s\n' "$winits" | grep -c ' registry$' || true) + if [ "$unpatched" != "0" ]; then + echo "::warning::${unpatched} winit copy/copies resolve from the registry and are NOT covered by [patch.crates-io] (it matches the 0.30.x requirement only). Their set_blur still calls the private SkyLight API; it is dead-stripped today, but this is the condition that regressed on 2026-08-03. Unifying the graph on winit 0.30.13 removes the risk — see rules/macos/appstore-private-api.md." + fi + + - name: Build iris-gui for both arches (lightning + CHD, App Store) + env: + # Baked into APP_VERSION by iris-gui/build.rs and shown in About. + # Must equal the CFBundleVersion the packaging job writes. + RELEASE_VERSION: ${{ needs.preflight.outputs.bundle_version }} + run: | + cargo build --release --target aarch64-apple-darwin -p iris-gui \ + --features iris/lightning,appstore + cargo build --release --target x86_64-apple-darwin -p iris-gui \ + --features iris/lightning,appstore + + # ── App Store gate: no private SkyLight API in the linked binaries ─────── + # eframe → egui-winit → winit calls the private `_CGSSetWindowBackgroundBlurRadius` + # (via `_CGSMainConnectionID`) in `WindowDelegate::set_blur`. IRIS never + # requests blur, but the import lands in the binary regardless and Apple's + # static scan rejects it under guideline 2.5.1 — submission 2ed07ab1 died + # exactly this way. third_party/winit-0.30.13 stubs the call out; this gate + # proves the stub actually took effect, on the linked binary, before we + # spend a job signing and uploading something Apple will bounce. + # + # `_CGShieldingWindowLevel` is PUBLIC CoreGraphics and legitimately appears + # in `nm -u` — never match on it. It is used here as a positive control: a + # silently-broken `nm` returning nothing would otherwise grep "clean". + # Do NOT try to attribute the symbol by running `nm` over rlibs in + # target/*/release/deps — the release profile is `lto = "fat"`, so those + # hold LLVM bitcode and `nm` reports "no symbols", which proves nothing. + - name: Check for private SkyLight API symbols + run: | + fail=0 + check() { + bin="$1" + if [ ! -f "$bin" ]; then + echo "::error::$bin does not exist — the private-API check could not run." + fail=1; return + fi + if ! undef=$(nm -u "$bin"); then + echo "::error::nm -u failed on $bin — failing rather than passing a check that did not run." + fail=1; return + fi + if ! printf '%s\n' "$undef" | grep -q '_CGShieldingWindowLevel'; then + echo "::error::$bin: nm -u returned $(printf '%s\n' "$undef" | wc -l | tr -d ' ') lines but not the expected PUBLIC _CGShieldingWindowLevel import. The control failed, so a clean private-API result can't be trusted — investigate before shipping." + fail=1; return + fi + if printf '%s\n' "$undef" | grep -qE '_CGSSetWindowBackgroundBlurRadius|_CGSMainConnectionID'; then + echo "::error::Private SkyLight API symbol found in $bin — App Store guideline 2.5.1 will reject this build. See rules/macos/appstore-private-api.md." + printf '%s\n' "$undef" | grep -E '_CGS' || true + fail=1; return + fi + echo "OK: no private SkyLight symbols in $bin" + } + check target/aarch64-apple-darwin/release/iris-gui + check target/x86_64-apple-darwin/release/iris-gui + exit $fail + + - name: Upload arm64 binary + uses: actions/upload-artifact@v7 + with: + name: iris-gui-arm64 + path: target/aarch64-apple-darwin/release/iris-gui + + - name: Upload x86_64 binary + uses: actions/upload-artifact@v7 + with: + name: iris-gui-x64 + path: target/x86_64-apple-darwin/release/iris-gui + + # ── Package and submit ─────────────────────────────────────────────────────── + + package-and-submit: + name: Package and Submit + needs: [preflight, build] + if: ${{ github.event.inputs.preflight_only != 'true' }} + runs-on: ${{ github.event.inputs.runner_image || 'macos-26' }} + env: + HAS_CERTS: ${{ secrets.APP_STORE_APP_CERT != '' }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.build_branch || 'main' }} + + # Reuse the version stamped by the preflight job so the binary's baked-in + # APP_VERSION and the Info.plist CFBundleVersion are identical (re-running + # `date` here would drift by the build duration). + - name: Generate version + id: version + run: | + echo "version=${{ needs.preflight.outputs.version }}" >> $GITHUB_OUTPUT + echo "bundle_version=${{ needs.preflight.outputs.bundle_version }}" >> $GITHUB_OUTPUT + + - name: Download binaries + uses: actions/download-artifact@v8 + with: + path: bins + + - name: Create universal binary with lipo + run: | + chmod +x bins/iris-gui-arm64/iris-gui bins/iris-gui-x64/iris-gui + lipo -create -output iris-gui-universal \ + bins/iris-gui-arm64/iris-gui \ + bins/iris-gui-x64/iris-gui + lipo -info iris-gui-universal + + - name: Import certificates + env: + APP_STORE_APP_CERT: ${{ secrets.APP_STORE_APP_CERT }} + APP_STORE_APP_CERT_PWD: ${{ secrets.APP_STORE_APP_CERT_PWD }} + APP_STORE_INSTALLER_CERT: ${{ secrets.APP_STORE_INSTALLER_CERT }} + APP_STORE_INSTALLER_CERT_PWD: ${{ secrets.APP_STORE_INSTALLER_CERT_PWD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/appstore.keychain-db + KEYCHAIN_PWD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + + import_cert() { + local b64="$1" pwd="$2" + local path=$RUNNER_TEMP/cert_$RANDOM.p12 + echo "$b64" | base64 --decode > "$path" + security import "$path" -P "$pwd" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + rm "$path" + } + + import_cert "$APP_STORE_APP_CERT" "$APP_STORE_APP_CERT_PWD" + import_cert "$APP_STORE_INSTALLER_CERT" "$APP_STORE_INSTALLER_CERT_PWD" + + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Resolve signing identities + id: identities + run: | + KEYCHAIN=$RUNNER_TEMP/appstore.keychain-db + # List every identity (cert+key pair) that imported. Both the App and + # Installer distribution identities must appear here. + echo "Identities in keychain:" + security find-identity "$KEYCHAIN" || true + + APP_ID=$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | grep "3rd Party Mac Developer Application" | head -1 \ + | sed 's/.*"\(.*\)".*/\1/') + # The installer (Mac Installer Distribution) cert isn't valid for the + # codesigning policy, so use the unfiltered list. It only shows up if + # the .p12 contained the certificate AND its private key. + INSTALLER_ID=$(security find-identity "$KEYCHAIN" \ + | grep "3rd Party Mac Developer Installer" | head -1 \ + | sed 's/.*"\(.*\)".*/\1/') + + if [ -z "$APP_ID" ]; then + echo "::error::No '3rd Party Mac Developer Application' identity in the keychain. APP_STORE_APP_CERT must be a base64 .p12 containing that certificate and its private key." + exit 1 + fi + if [ -z "$INSTALLER_ID" ]; then + echo "::error::No '3rd Party Mac Developer Installer' identity in the keychain. APP_STORE_INSTALLER_CERT must be a base64 .p12 that contains the Mac Installer Distribution certificate AND its private key — export the *identity* from Keychain Access (the row with a disclosure triangle hiding a private key), not just the certificate. Also confirm APP_STORE_INSTALLER_CERT_PWD matches the export password." + exit 1 + fi + + echo "app_identity=$APP_ID" >> $GITHUB_OUTPUT + echo "installer_identity=$INSTALLER_ID" >> $GITHUB_OUTPUT + echo "App identity: $APP_ID" + echo "Installer identity: $INSTALLER_ID" + + - name: Embed provisioning profile and build .app bundle + env: + APP_STORE_PROVISIONING_PROFILE: ${{ secrets.APP_STORE_PROVISIONING_PROFILE }} + VER: ${{ steps.version.outputs.version }} + BUNDLE_VER: ${{ steps.version.outputs.bundle_version }} + run: | + BUNDLE="IRIS.app" + mkdir -p "${BUNDLE}/Contents/MacOS" "${BUNDLE}/Contents/Resources" + + cp iris-gui-universal "${BUNDLE}/Contents/MacOS/iris-gui" + chmod +x "${BUNDLE}/Contents/MacOS/iris-gui" + cp "iris-gui/assets/icons/icon.icns" "${BUNDLE}/Contents/Resources/AppIcon.icns" + + # Embed provisioning profile (required for App Store) + echo "$APP_STORE_PROVISIONING_PROFILE" | base64 --decode \ + > "${BUNDLE}/Contents/embedded.provisionprofile" + + cat > "${BUNDLE}/Contents/Info.plist" << PLIST + + + + + CFBundleNameIRIS + CFBundleDisplayNameIRIS + CFBundleIdentifier${{ env.BUNDLE_ID }} + CFBundleVersion${BUNDLE_VER} + CFBundleShortVersionString${BUNDLE_VER} + CFBundleExecutableiris-gui + CFBundleIconFileAppIcon.icns + CFBundlePackageTypeAPPL + NSHighResolutionCapable + LSMinimumSystemVersion10.13 + LSApplicationCategoryTypepublic.app-category.developer-tools + NSCameraUsageDescriptionProvides the IndyCam video input for SGI Indy emulation (VINO device). + + ITSAppUsesNonExemptEncryption + + + PLIST + + - name: Sign .app with entitlements + env: + IDENTITY: ${{ steps.identities.outputs.app_identity }} + run: | + codesign --force --options runtime \ + --entitlements installer/iris-gui.entitlements \ + --sign "$IDENTITY" \ + IRIS.app + codesign --verify --deep --strict IRIS.app + echo "Signature verified." + + # Second half of the guideline-2.5.1 gate (the first runs on the thin + # binaries in the build job). Cheap, and it covers the universal binary as + # actually shipped — a wrong artifact download or a stale lipo input can't + # sneak the private symbol past the earlier check. `nm -u` on a fat binary + # lists both slices' undefined symbols. + - name: Re-check the signed bundle for private SkyLight symbols + run: | + BIN="IRIS.app/Contents/MacOS/iris-gui" + undef=$(nm -u "$BIN") || { echo "::error::nm -u failed on $BIN"; exit 1; } + if ! printf '%s\n' "$undef" | grep -q '_CGShieldingWindowLevel'; then + echo "::error::$BIN: the public _CGShieldingWindowLevel control import is missing, so this check can't be trusted." + exit 1 + fi + if printf '%s\n' "$undef" | grep -qE '_CGSSetWindowBackgroundBlurRadius|_CGSMainConnectionID'; then + echo "::error::Private SkyLight API symbol found in the shipping bundle $BIN — App Store guideline 2.5.1 will reject this. See rules/macos/appstore-private-api.md." + printf '%s\n' "$undef" | grep -E '_CGS' || true + exit 1 + fi + echo "OK: no private SkyLight symbols in $BIN" + + - name: Package with productbuild + env: + IDENTITY: ${{ steps.identities.outputs.installer_identity }} + VER: ${{ steps.version.outputs.version }} + run: | + productbuild \ + --component IRIS.app /Applications \ + --sign "$IDENTITY" \ + "IRIS-appstore-${VER}.pkg" + + - name: Validate package + env: + VER: ${{ steps.version.outputs.version }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + run: | + # altool locates the key by convention: AuthKey_.p8 under + # ~/.appstoreconnect/private_keys (it has no --apiPrivateKeyPath flag). + mkdir -p "$HOME/.appstoreconnect/private_keys" + KEYF="$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" + echo "$APP_STORE_CONNECT_PRIVATE_KEY" | base64 --decode > "$KEYF" + xcrun altool --validate-app \ + --type osx \ + --file "IRIS-appstore-${VER}.pkg" \ + --apiKey "$APP_STORE_CONNECT_KEY_ID" \ + --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" + rm -f "$KEYF" + + - name: Submit to App Store Connect + if: ${{ github.event.inputs.validate_only != 'true' }} + env: + VER: ${{ steps.version.outputs.version }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + run: | + mkdir -p "$HOME/.appstoreconnect/private_keys" + KEYF="$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" + echo "$APP_STORE_CONNECT_PRIVATE_KEY" | base64 --decode > "$KEYF" + xcrun altool --upload-app \ + --type osx \ + --file "IRIS-appstore-${VER}.pkg" \ + --apiKey "$APP_STORE_CONNECT_KEY_ID" \ + --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" + rm -f "$KEYF" + echo "Submitted. Check App Store Connect for processing status." + + - name: Upload .pkg as artifact + uses: actions/upload-artifact@v7 + with: + name: IRIS-appstore-${{ steps.version.outputs.version }} + path: IRIS-appstore-${{ steps.version.outputs.version }}.pkg + + - name: Cleanup keychain + if: always() + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/appstore.keychain-db + [ -f "$KEYCHAIN_PATH" ] && security delete-keychain "$KEYCHAIN_PATH" || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3a4a5be --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,1045 @@ +# Build and release pipeline for IRIS. +# +# Manual trigger only (workflow_dispatch), in one of two modes: +# +# publish_release UNCHECKED (the default) +# Builds every artifact on every platform and verifies the release contents, +# then stops. No tag, no release. Use this to prove a change builds +# everywhere; the artifacts are attached to the run. +# +# publish_release CHECKED +# The same build, then tags v (UTC, generated at dispatch) +# at the built commit and publishes a release carrying the downloads table +# generated below. +# +# Builds check out `main` by default. Use the build_branch input to build a +# different branch for testing; a published tag follows that branch's commit. +# +# ── What ships ──────────────────────────────────────────────────────────────── +# +# One build variant per platform/arch: lightning + rex-jit + camera + chd. +# +# It is a lightning build (interpreter with breakpoint checks and the traceback +# buffer stripped — no interactive debugging) and carries the REX3 draw-shader +# JIT. It does not carry the v1 MIPS JIT (`jit`) or the N64 dev board +# (`ultra64`); those are source-build opt-ins now (see iris-gui/Cargo.toml). The +# former "standard" (non-lightning), `pcap`, `-jitv2`, and separate `-r5000` +# variants and the chd_extract tool are no longer released — all of them remain +# buildable from source. +# +# Each platform ships iris-gui (installer + portable on Windows, .dmg on macOS, +# AppImage/deb/rpm/pkg on Linux, tarball on riscv64) and a Core tarball holding +# the `iris` CLI + `iris-ci` automation client. +# +# Optional repo secrets for macOS code signing + notarization: +# MACOS_CERTIFICATE base64-encoded Developer ID Application .p12 certificate +# MACOS_CERTIFICATE_PWD password for the .p12 file +# MACOS_NOTARIZE_APPLE_ID your Apple ID email +# MACOS_NOTARIZE_PASSWORD app-specific password from appleid.apple.com +# MACOS_TEAM_ID 10-character Apple Developer Team ID +# Without these, builds use ad-hoc signing and are not notarized (Gatekeeper will warn). + +name: Release + +on: + workflow_dispatch: + inputs: + publish_release: + # Unchecked: build every artifact and verify the release contents, but + # create no tag and no release — a dry run that still surfaces breakage. + # Checked: same build, then tag v and publish to Releases. + description: "Publish a release (tags v and posts to Releases). Leave unchecked to build artifacts only." + type: boolean + required: false + default: false + build_branch: + description: Override branch to check out (leave empty to build main) + required: false + default: '' + runner_image: + # macOS runner image for build-macos (both arches build on this one + # runner). Pinned rather than `macos-latest` so a run is deterministic + # during GitHub's macos-15 → macos-26 migration, where `macos-latest` + # resolves to either image unpredictably. Override here to test another + # image without editing the workflow. Same knob as appstore.yml. + description: "macOS runner image (default macos-26; override with macos-latest/macos-15)" + required: false + default: macos-26 + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + # Pinned quick-sharun + appimagetool versions (same as rusty-backup). + ANYLINUX_REF: 2affcd69e3b3fccab4507dbdbaed5d1a04bedfa9 + APPIMAGETOOL_VERSION: "0.3.0" + +jobs: + # ── Version ────────────────────────────────────────────────────────────────── + + generate-version: + name: Generate Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + sha: ${{ steps.version.outputs.sha }} + steps: + # Resolve the commit the build legs will check out so a published release + # tags what was actually built. Without this the tag lands on the ref the + # run was dispatched from, which is the wrong commit whenever build_branch + # is set. + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.build_branch || 'main' }} + + - name: Generate version from date + id: version + run: | + VERSION=$(date -u +"%Y-%m-%d-%H-%M") + SHA=$(git rev-parse HEAD) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + echo "sha=$SHA" >> $GITHUB_OUTPUT + echo "Generated version: $VERSION (tag v$VERSION at $SHA)" + + # NOTE: use `inputs.publish_release` (a real boolean), never + # `github.event.inputs.publish_release` — that is the STRING "false", + # which is truthy in an `if:` and would publish on every run. + - name: Announce mode + run: | + if [ "${{ inputs.publish_release }}" = "true" ]; then + echo "### Release run - will publish \`v${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + else + echo "### Build-only run - no tag, no release" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Artifacts are attached to this run. Re-run with **Publish a release** checked to tag and publish." >> $GITHUB_STEP_SUMMARY + fi + + # ── Windows ────────────────────────────────────────────────────────────────── + # + # x64 + arm64: iris-gui (installer + portable, per CPU variant) and the Core + # zip (iris + iris-ci). arm64 builds natively on the windows-11-arm runner. + # (32-bit x86 dropped — earlier libchdman-rs had no i686 build; it does now, but + # we don't ship 32-bit.) libchdman-rs ≥ 0.288.8 ships aarch64-pc-windows-msvc + # prebuilts, so CHD works on arm64. + + build-windows: + name: Build Windows (${{ matrix.arch }}) + needs: generate-version + runs-on: ${{ matrix.runs-on }} + env: + RELEASE_VERSION: ${{ needs.generate-version.outputs.version }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + arch: x64 + runs-on: windows-latest + cli_features: "chd,camera,rex-jit,lightning" + - target: aarch64-pc-windows-msvc + arch: arm64 + runs-on: windows-11-arm + cli_features: "chd,camera,rex-jit,lightning" + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.build_branch || 'main' }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Create combined license (for installer) + shell: bash + run: | + printf '%s\n\n\n---\n\n\n' "$(cat LICENSE)" > COMBINED-LICENSE.txt + cat LICENSE-libchdman-rs.txt >> COMBINED-LICENSE.txt + + - name: Install Inno Setup + shell: pwsh + run: choco install innosetup --no-progress -y + + # ── iris-gui (x64 + arm64) ────────────────────────────────────────────── + + - name: Build iris-gui + run: cargo build --release --target ${{ matrix.target }} -p iris-gui --features iris/lightning,bundled + + - name: Build installer + shell: pwsh + run: | + $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + if (-not (Test-Path $iscc)) { throw "ISCC.exe not found" } + $ver = "${{ needs.generate-version.outputs.version }}" + $src = "$env:GITHUB_WORKSPACE\target\${{ matrix.target }}\release" + $assets = "$env:GITHUB_WORKSPACE\iris-gui\assets\icons" + $lic = "$env:GITHUB_WORKSPACE\COMBINED-LICENSE.txt" + & $iscc "/DMyAppVersion=$ver" "/DTargetArch=${{ matrix.arch }}" "/DSourceDir=$src" "/DAssetsDir=$assets" ` + "/DLicenseFile=$lic" "/FIRIS-Setup-windows-${{ matrix.arch }}-$ver" ` + installer\iris-gui.iss + if ($LASTEXITCODE -ne 0) { throw "iscc failed" } + + - name: Package iris-gui (portable) + shell: bash + run: | + VER="${{ needs.generate-version.outputs.version }}" + 7z a "IRIS-gui-windows-${{ matrix.arch }}-${VER}.zip" \ + "target/${{ matrix.target }}/release/iris-gui.exe" \ + "iris-gui/assets/icons/icon.ico" \ + LICENSE LICENSE-libchdman-rs.txt + + # ── Core: iris CLI + iris-ci ──────────────────────────────────────────── + + - name: Build iris CLI + run: cargo build --release --bin iris --target ${{ matrix.target }} --features ${{ matrix.cli_features }} + + - name: Build iris-ci + run: cargo build --release --bin iris-ci --target ${{ matrix.target }} --features ${{ matrix.cli_features }} + + # Staging into a temp dir and zipping from inside it keeps the archive flat + # (iris.exe/iris-ci.exe/licenses, no target/... paths). + - name: Package Core + shell: bash + run: | + VER="${{ needs.generate-version.outputs.version }}" + rm -rf _pkg_tmp && mkdir -p _pkg_tmp + cp "target/${{ matrix.target }}/release/iris.exe" _pkg_tmp/ + cp "target/${{ matrix.target }}/release/iris-ci.exe" _pkg_tmp/ + cp LICENSE LICENSE-libchdman-rs.txt _pkg_tmp/ + (cd _pkg_tmp && 7z a "../IRIS-cli-windows-${{ matrix.arch }}-${VER}.zip" *) + rm -rf _pkg_tmp + + - name: Verify CLI archive contents + shell: bash + run: | + set -e + for f in IRIS-cli-*.zip; do + listing=$(7z l -ba -slt "$f" | sed -n 's/^Path = //p') + for want in iris.exe iris-ci.exe LICENSE LICENSE-libchdman-rs.txt; do + echo "$listing" | grep -qx "$want" \ + || { echo "::error::${f} is missing flat member '${want}'"; exit 1; } + done + done + + # ── Upload ────────────────────────────────────────────────────────────── + + - name: Upload iris-gui portable + uses: actions/upload-artifact@v7 + with: + name: IRIS-gui-windows-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: IRIS-gui-windows-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.zip + + - name: Upload iris-gui installer + uses: actions/upload-artifact@v7 + with: + name: IRIS-Setup-windows-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: installer\Output\IRIS-Setup-windows-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.exe + + - name: Upload Core + uses: actions/upload-artifact@v7 + with: + name: IRIS-cli-windows-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: IRIS-cli-windows-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.zip + + # ── macOS ──────────────────────────────────────────────────────────────────── + + build-macos: + name: Build macOS (${{ matrix.arch }}) + needs: generate-version + runs-on: ${{ github.event.inputs.runner_image || 'macos-26' }} + # Guard against a hung actions/cache step sitting for the 6h default. Three + # compiles (GUI + iris + iris-ci) and three notarytool submissions per leg + # (the DMG, the iris CLI, iris-ci — each `--wait`); the ceiling leaves room + # for an Apple-side queue spike without masking a hang. + timeout-minutes: 120 + env: + HAS_SIGNING_CERT: ${{ secrets.MACOS_CERTIFICATE != '' }} + RELEASE_VERSION: ${{ needs.generate-version.outputs.version }} + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + arch: arm64 + - target: x86_64-apple-darwin + arch: x64 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.build_branch || 'main' }} + + - name: Add the target to the pinned (nightly) toolchain + run: | + # rust-toolchain.toml pins nightly, so cargo uses THAT — not stable, so + # installing a target via dtolnay/rust-toolchain@stable wouldn't help. + # `rustup show` (run in the checked-out repo) auto-installs the pinned + # toolchain; then add this leg's target to it. The host arm64 std comes + # with the toolchain, but x86_64 must be added explicitly now that it is + # cross-compiled here — without it rustc can't find core/std. Mirrors + # appstore.yml and the riscv64 job below. + rustup show + rustup target add ${{ matrix.target }} + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # ── Build all binaries ────────────────────────────────────────────────── + + - name: Build all binaries + run: | + cargo build --release --target ${{ matrix.target }} -p iris-gui --features iris/lightning,bundled + cargo build --release --bin iris --target ${{ matrix.target }} --features chd,camera,rex-jit,lightning + cargo build --release --bin iris-ci --target ${{ matrix.target }} --features chd,camera,rex-jit,lightning + + # ── Code signing setup ────────────────────────────────────────────────── + + - name: Import signing certificate + if: ${{ env.HAS_SIGNING_CERT == 'true' }} + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PWD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$MACOS_CERTIFICATE" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$MACOS_CERTIFICATE_PWD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + rm "$CERT_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Resolve signing identity + id: signing + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + run: | + if [ -n "$MACOS_CERTIFICATE" ]; then + ID=$(security find-identity -v -p codesigning "$RUNNER_TEMP/app-signing.keychain-db" \ + | grep "Developer ID Application" | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "identity=$ID" >> $GITHUB_OUTPUT + else + echo "identity=-" >> $GITHUB_OUTPUT + fi + + # ── iris-gui: app bundles + DMGs ───────────────────────────────────────── + + - name: Create iris-gui app bundles and DMGs + env: + VER: ${{ needs.generate-version.outputs.version }} + IDENTITY: ${{ steps.signing.outputs.identity }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_TEAM_ID: ${{ secrets.MACOS_TEAM_ID }} + MACOS_NOTARIZE_APPLE_ID: ${{ secrets.MACOS_NOTARIZE_APPLE_ID }} + MACOS_NOTARIZE_PASSWORD: ${{ secrets.MACOS_NOTARIZE_PASSWORD }} + run: | + set -e + SRC="target/${{ matrix.target }}/release" + ARCH="${{ matrix.arch }}" + + make_dmg() { + local bin_src="$1" # path to the built iris-gui binary + local dmg_name="IRIS-gui-macos-${ARCH}-${VER}.dmg" + local bundle="IRIS.app" + + rm -rf "$bundle" dmg-staging + mkdir -p "${bundle}/Contents/MacOS" "${bundle}/Contents/Resources" + cp "$bin_src" "${bundle}/Contents/MacOS/iris-gui" + chmod +x "${bundle}/Contents/MacOS/iris-gui" + cp "iris-gui/assets/icons/icon.icns" "${bundle}/Contents/Resources/AppIcon.icns" + cp LICENSE "${bundle}/Contents/Resources/LICENSE" + cp LICENSE-libchdman-rs.txt "${bundle}/Contents/Resources/LICENSE-libchdman-rs.txt" + + cat > "${bundle}/Contents/Info.plist" << PLIST + + + + + CFBundleNameIRIS + CFBundleDisplayNameIRIS + CFBundleIdentifierio.github.danifunker.iris + CFBundleVersion${VER} + CFBundleShortVersionString${VER} + CFBundleExecutableiris-gui + CFBundleIconFileAppIcon.icns + CFBundlePackageTypeAPPL + NSHighResolutionCapable + LSMinimumSystemVersion10.13 + LSApplicationCategoryTypepublic.app-category.developer-tools + NSCameraUsageDescriptionProvides the IndyCam video input for SGI Indy emulation (VINO device). + + + PLIST + + if [ "$IDENTITY" != "-" ]; then + # --entitlements grants allow-unsigned-executable-memory so the + # hardened runtime doesn't kill Cranelift's mmap+mprotect JIT pages + # (the REX3 draw-shader JIT). Permitted for Developer ID/notarization; + # the App Store build can't use it and runs interpreter-only instead. + codesign --force --options runtime \ + --entitlements installer/iris-gui-notarized.entitlements \ + --sign "$IDENTITY" "${bundle}" + else + codesign --force --deep --sign - "${bundle}" + fi + + mkdir -p dmg-staging + cp -R "${bundle}" dmg-staging/ + ln -sf /Applications dmg-staging/Applications + hdiutil create -volname "IRIS" -srcfolder dmg-staging -ov -format UDZO "${dmg_name}" + + if [ "$IDENTITY" != "-" ]; then + codesign --force --sign "$IDENTITY" "${dmg_name}" + fi + + if [ -n "$MACOS_NOTARIZE_APPLE_ID" ] && [ -n "$MACOS_TEAM_ID" ]; then + xcrun notarytool submit "${dmg_name}" \ + --apple-id "$MACOS_NOTARIZE_APPLE_ID" \ + --password "$MACOS_NOTARIZE_PASSWORD" \ + --team-id "$MACOS_TEAM_ID" \ + --wait + xcrun stapler staple "${dmg_name}" + fi + } + + make_dmg "${SRC}/iris-gui" + + # ── Core (iris + iris-ci): sign + package ─────────────────────────────── + + - name: Sign and package Core binaries + env: + VER: ${{ needs.generate-version.outputs.version }} + IDENTITY: ${{ steps.signing.outputs.identity }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_TEAM_ID: ${{ secrets.MACOS_TEAM_ID }} + MACOS_NOTARIZE_APPLE_ID: ${{ secrets.MACOS_NOTARIZE_APPLE_ID }} + MACOS_NOTARIZE_PASSWORD: ${{ secrets.MACOS_NOTARIZE_PASSWORD }} + run: | + set -e + SRC="target/${{ matrix.target }}/release" + ARCH="${{ matrix.arch }}" + + sign_notarize() { + local bin_src="$1" + local tag="$2" # unique basename for the notarize zip + + if [ "$IDENTITY" != "-" ]; then + # See the GUI bundle signing above: the JIT entitlement keeps the + # notarized CLI binaries (built with rex-jit) from being killed by + # the hardened runtime on their first JITed REX3 draw. + codesign --force --options runtime --timestamp \ + --entitlements installer/iris-gui-notarized.entitlements \ + --sign "$IDENTITY" "$bin_src" + else + codesign --force --sign - "$bin_src" + fi + + if [ -n "$MACOS_NOTARIZE_APPLE_ID" ] && [ -n "$MACOS_TEAM_ID" ]; then + local zip_tmp="${tag}-notarize.zip" + ditto -c -k "$bin_src" "$zip_tmp" + xcrun notarytool submit "$zip_tmp" \ + --apple-id "$MACOS_NOTARIZE_APPLE_ID" \ + --password "$MACOS_NOTARIZE_PASSWORD" \ + --team-id "$MACOS_TEAM_ID" \ + --wait + rm -f "$zip_tmp" + fi + } + + sign_notarize_package() { + local bin_src="$1" + local out_name="$2" + + sign_notarize "$bin_src" "${out_name%.tar.gz}" + + mkdir -p _pkg_tmp + cp "$bin_src" _pkg_tmp/iris + cp "${SRC}/iris-ci" _pkg_tmp/iris-ci + cp LICENSE "_pkg_tmp/" + cp LICENSE-libchdman-rs.txt "_pkg_tmp/" + tar -C _pkg_tmp -czf "${out_name}" iris iris-ci LICENSE LICENSE-libchdman-rs.txt + rm -rf _pkg_tmp + } + + # iris-ci ships in the Core tarball; sign + notarize it first, then the + # pkg call below just stages the already-signed binary. + sign_notarize "${SRC}/iris-ci" "iris-ci-macos-${ARCH}-${VER}" + + sign_notarize_package "${SRC}/iris" "IRIS-cli-macos-${ARCH}-${VER}.tar.gz" + + - name: Verify CLI archive contents + run: | + set -e + for f in IRIS-cli-*.tar.gz; do + listing=$(tar tzf "$f") + for want in iris iris-ci LICENSE LICENSE-libchdman-rs.txt; do + echo "$listing" | grep -qx "$want" \ + || { echo "::error::${f} is missing flat member '${want}'"; exit 1; } + done + done + + - name: Cleanup keychain + if: always() + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + [ -f "$KEYCHAIN_PATH" ] && security delete-keychain "$KEYCHAIN_PATH" || true + + # ── Upload ────────────────────────────────────────────────────────────── + + - name: Upload iris-gui DMG + uses: actions/upload-artifact@v7 + with: + name: IRIS-gui-macos-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: IRIS-gui-macos-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.dmg + + - name: Upload Core + uses: actions/upload-artifact@v7 + with: + name: IRIS-cli-macos-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: IRIS-cli-macos-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.tar.gz + + # ── Linux AppImage ──────────────────────────────────────────────────────────── + # + # Arch Linux container + quick-sharun → self-contained AppImage for iris-gui. + + build-linux-appimage: + name: Build Linux AppImage (${{ matrix.arch }}) + needs: generate-version + runs-on: ${{ matrix.runs-on }} + container: ghcr.io/pkgforge-dev/archlinux:latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + arch: x64 + sharun_arch: x86_64 + runs-on: ubuntu-24.04 + - target: aarch64-unknown-linux-gnu + arch: arm64 + sharun_arch: aarch64 + runs-on: ubuntu-24.04-arm + env: + RELEASE_VERSION: ${{ needs.generate-version.outputs.version }} + steps: + - name: Install build prerequisites + run: | + pacman -Syu --noconfirm \ + base-devel cmake rust \ + clang \ + alsa-lib \ + gtk3 \ + openssl pkgconf \ + v4l-utils \ + wget curl git \ + libxcb libxcursor libxi libxkbcommon libxkbcommon-x11 libxrandr libxtst \ + wayland wayland-protocols \ + mesa vulkan-icd-loader \ + xorg-server-xvfb strace dbus file patchelf \ + desktop-file-utils shared-mime-info zsync + + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.build_branch || 'main' }} + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.arch }}-appimage-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install debloated runtime packages + run: | + wget --retry-connrefused --tries=30 \ + "https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/${ANYLINUX_REF}/useful-tools/get-debloated-pkgs.sh" \ + -O ./get-debloated-pkgs.sh + chmod +x ./get-debloated-pkgs.sh + ./get-debloated-pkgs.sh --add-common --add-mesa --prefer-nano + + - name: Download quick-sharun + run: | + wget --retry-connrefused --tries=30 \ + "https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/${ANYLINUX_REF}/useful-tools/quick-sharun.sh" \ + -O ./quick-sharun + chmod +x ./quick-sharun + + - name: Stage shared AppDir inputs (icons + desktop) + run: | + install -Dm644 "iris-gui/assets/icons/icon-256.png" \ + /usr/share/icons/hicolor/256x256/apps/iris-gui.png + install -d /usr/share/applications + cat > /usr/share/applications/iris-gui.desktop << 'EOF' + [Desktop Entry] + Type=Application + Name=IRIS + Comment=SGI Indy (MIPS R4400) emulator + Exec=iris-gui + Icon=iris-gui + Categories=Emulator;Game; + Terminal=false + EOF + + - name: Build and bundle iris-gui + env: + VER: ${{ needs.generate-version.outputs.version }} + run: | + cargo build --release --target ${{ matrix.target }} -p iris-gui --features iris/lightning + install -Dm755 "target/${{ matrix.target }}/release/iris-gui" /usr/bin/iris-gui + + OUT="IRIS-gui-linux-${{ matrix.arch }}-${VER}.AppImage" + export OUTPATH="$PWD/dist" + export OUTNAME="$OUT" + export VERSION="${VER}" + export ICON=/usr/share/icons/hicolor/256x256/apps/iris-gui.png + export DESKTOP=/usr/share/applications/iris-gui.desktop + export APPIMAGETOOL_LINK="https://github.com/pkgforge-dev/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${{ matrix.sharun_arch }}-linux" + mkdir -p "$OUTPATH" + ./quick-sharun /usr/bin/iris-gui + ./quick-sharun --make-appimage + ls -lh "$OUTPATH" + + - name: Upload AppImage + uses: actions/upload-artifact@v7 + with: + name: IRIS-appimage-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: | + dist/IRIS-gui-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.AppImage + dist/IRIS-gui-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.AppImage.zsync + + # ── Linux packages + Core tarballs ──────────────────────────────────────────── + # + # deb, rpm, Arch pkg for iris-gui; tar.gz for the Core (iris + iris-ci). + + build-linux-packages: + name: Build Linux packages (${{ matrix.arch }}) + needs: generate-version + runs-on: ${{ matrix.runs-on }} + env: + RELEASE_VERSION: ${{ needs.generate-version.outputs.version }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + arch: x64 + runs-on: ubuntu-24.04 + - target: aarch64-unknown-linux-gnu + arch: arm64 + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.build_branch || 'main' }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + pkg-config \ + clang libclang-dev \ + libasound2-dev \ + libgtk-3-dev \ + libssl-dev \ + libv4l-dev \ + libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libwayland-dev \ + libgl-dev \ + dpkg-dev liblzma-dev rpm libarchive-tools zstd + + - name: Install cargo packaging tools + run: | + cargo install cargo-deb + cargo install cargo-generate-rpm + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-packages-cargo-${{ hashFiles('**/Cargo.lock') }} + + # ── iris-gui packages ─────────────────────────────────────────────────── + + - name: Build iris-gui + run: cargo build --release --target ${{ matrix.target }} -p iris-gui --features iris/lightning + + - name: Package iris-gui (deb + rpm + arch) + run: | + VER="${{ needs.generate-version.outputs.version }}" + ARCH="${{ matrix.arch }}" + TARGET="${{ matrix.target }}" + + cargo deb -p iris-gui --target "$TARGET" --no-build + mv "target/${TARGET}/debian/"*.deb "IRIS-gui-linux-${ARCH}-${VER}.deb" + + cargo generate-rpm -p iris-gui --target "$TARGET" + mv "target/${TARGET}/generate-rpm/"*.rpm "IRIS-gui-linux-${ARCH}-${VER}.rpm" + + mkdir -p arch-pkg/usr/bin arch-pkg/usr/share/applications \ + arch-pkg/usr/share/icons/hicolor/{16x16,32x32,48x48,64x64,128x128,256x256}/apps \ + arch-pkg/usr/share/doc/iris-gui + cp "target/${TARGET}/release/iris-gui" arch-pkg/usr/bin/ + chmod 755 arch-pkg/usr/bin/iris-gui + for size in 16 32 48 64 128 256; do + cp "iris-gui/assets/icons/icon-${size}.png" \ + "arch-pkg/usr/share/icons/hicolor/${size}x${size}/apps/iris-gui.png" + done + cp iris-gui/iris-gui.desktop arch-pkg/usr/share/applications/ + cp LICENSE arch-pkg/usr/share/doc/iris-gui/ + cp LICENSE-libchdman-rs.txt arch-pkg/usr/share/doc/iris-gui/ + (cd arch-pkg && tar -czf "../IRIS-gui-linux-${ARCH}-${VER}.pkg.tar.zst" usr) + rm -rf arch-pkg + + # ── Core tarball ──────────────────────────────────────────────────────── + + - name: Build iris CLI and iris-ci + run: | + cargo build --release --bin iris --target ${{ matrix.target }} --features chd,camera,rex-jit,lightning + cargo build --release --bin iris-ci --target ${{ matrix.target }} --features chd,camera,rex-jit,lightning + + - name: Package Core tarball + run: | + VER="${{ needs.generate-version.outputs.version }}" + ARCH="${{ matrix.arch }}" + SRC="target/${{ matrix.target }}/release" + + pkg_cli() { + local src="$1" label="$2" + mkdir -p _tmp + cp "$src" _tmp/iris + cp "${SRC}/iris-ci" _tmp/iris-ci + cp LICENSE _tmp/ + cp LICENSE-libchdman-rs.txt _tmp/ + tar -C _tmp -czf "${label}" iris iris-ci LICENSE LICENSE-libchdman-rs.txt + rm -rf _tmp + } + + pkg_cli "${SRC}/iris" "IRIS-cli-linux-${ARCH}-${VER}.tar.gz" + + - name: Verify CLI archive contents + run: | + set -e + for f in IRIS-cli-*.tar.gz; do + listing=$(tar tzf "$f") + for want in iris iris-ci LICENSE LICENSE-libchdman-rs.txt; do + echo "$listing" | grep -qx "$want" \ + || { echo "::error::${f} is missing flat member '${want}'"; exit 1; } + done + done + + # ── Upload ────────────────────────────────────────────────────────────── + + - name: Upload iris-gui packages + uses: actions/upload-artifact@v7 + with: + name: IRIS-gui-packages-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: | + IRIS-gui-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.deb + IRIS-gui-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.rpm + IRIS-gui-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.pkg.tar.zst + + - name: Upload Core + uses: actions/upload-artifact@v7 + with: + name: IRIS-cli-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: IRIS-cli-linux-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }}.tar.gz + + # ── Linux riscv64 (cross, tarball-only) ─────────────────────────────────────── + # + # No GitHub-hosted riscv64 runner exists, so we cross-compile from an x86_64 + # Debian trixie host using multiarch: riscv64 is an official Debian 13 release + # arch, so the `:riscv64` -dev packages install cleanly alongside the host's. + # libchdman-rs ≥ 0.288.8 ships a riscv64gc-unknown-linux-gnu prebuilt, so CHD + # works here. Shipped as tarballs only (no deb/rpm/AppImage — the AppImage + # sharun tooling has no riscv64 path). + build-linux-riscv64: + name: Build Linux riscv64 (cross) + needs: generate-version + runs-on: ubuntu-24.04 + container: debian:trixie + env: + RELEASE_VERSION: ${{ needs.generate-version.outputs.version }} + TARGET: riscv64gc-unknown-linux-gnu + ARCH: riscv64 + # The container runs as root (euid home /root) but Actions sets + # HOME=/github/home; rustup hard-errors on that mismatch ("$HOME differs + # from euid-obtained home directory") and never installs the target std, + # so rustc reports "can't find crate for `core`". Force HOME to root's home + # and pin rustup/cargo to fixed paths so install and build agree. + HOME: /root + RUSTUP_HOME: /usr/local/rustup + CARGO_HOME: /usr/local/cargo + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc + CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++ + AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar + PKG_CONFIG_ALLOW_CROSS: "1" + PKG_CONFIG_SYSROOT_DIR: "/" + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + BINDGEN_EXTRA_CLANG_ARGS: "--target=riscv64-unknown-linux-gnu -I/usr/include/riscv64-linux-gnu" + steps: + - name: Install host toolchain + riscv64 cross sysroot + run: | + set -eux + dpkg --add-architecture riscv64 + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils git pkg-config \ + build-essential crossbuild-essential-riscv64 \ + clang libclang-dev llvm \ + libasound2-dev:riscv64 libgtk-3-dev:riscv64 libssl-dev:riscv64 \ + libv4l-dev:riscv64 \ + libxcb-render0-dev:riscv64 libxcb-shape0-dev:riscv64 libxcb-xfixes0-dev:riscv64 \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libgl-dev:riscv64 + + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.build_branch || 'main' }} + + - name: Install Rust + riscv64 std for the pinned toolchain + run: | + set -eux + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain none --no-modify-path + export PATH="$CARGO_HOME/bin:$PATH" + # rust-toolchain.toml pins nightly; `rustup show` (run in the checked-out + # repo) auto-installs it, then add the riscv64 std to THAT toolchain — + # adding it to stable wouldn't help since the build uses nightly. + rustup show + rustup target add "$TARGET" + echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" + + # iris-gui always pulls camera (v4l) via its iris dep, so libv4l-dev:riscv64 + # is installed above for the bindgen cross. + - name: Build iris-gui (cross) + run: | + set -eux + cargo build --release --target "$TARGET" -p iris-gui --features iris/lightning,bundled + + - name: Build iris CLI + iris-ci (cross) + run: | + set -eux + cargo build --release --bin iris --target "$TARGET" --features chd,camera,rex-jit,lightning + cargo build --release --bin iris-ci --target "$TARGET" --features chd,camera,rex-jit,lightning + + - name: Package tarballs + run: | + set -eux + VER="${RELEASE_VERSION}" + REL="target/${TARGET}/release" + + pkg_gui() { #