From cf4c319cb4939c8b77a2abae96f7eea8e38f3c92 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:12:57 +0200 Subject: [PATCH] chore(ci): move macOS release pipeline to GitHub Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port scripts/release.sh to .github/workflows/release-macos.yml (workflow_dispatch): Tauri build, Developer-ID codesign with entitlements, notarization + stapling via App Store Connect API key, DMG, signed updater bundle + latest.json, tag + GitHub release, PyPI publish. Keychain/notary steps follow the host-admin macos-sign.yml template (inlined — aiui signs three artifacts in sequence and builds the DMG from the stapled app, which a single artifact-path call can't express). Cost design per CI playbook: dispatch-only trigger, single macOS job, version-sync check first so a drifted dispatch fails in seconds. scripts/release.sh stays as documented emergency fallback; comments in ci.yml and release-windows.yml updated to point at the new workflow. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 +- .github/workflows/release-macos.yml | 302 ++++++++++++++++++++++++++ .github/workflows/release-windows.yml | 19 +- CHANGELOG.md | 10 + scripts/release.sh | 7 + 5 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release-macos.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a19280a..947c899 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,8 @@ jobs: # Windows-only: produce a real NSIS installer so we can hand the # `.exe` to a tester or run it on a Windows VM. Mac CI stays - # bundle-less — releases are still cut manually via scripts/release.sh. + # bundle-less — macOS release bundles are built by release-macos.yml + # (workflow_dispatch). - name: Install Tauri CLI if: matrix.build_bundle run: npm install --global @tauri-apps/cli@^2 diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..a8f74aa --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,302 @@ +# macOS release pipeline for aiui — CI port of scripts/release.sh. +# +# Why CI and not the maintainer's Mac: signing + notarization run +# exclusively in GitHub Actions per the global convention +# (~/.claude/references/macos_signing.md — Developer ID Application +# certificate + App Store Connect API key, no Apple-ID password, no 2FA). +# The keychain/notary steps below are inlined from the reusable template +# `iret77/host-admin skills/devhost-setup/templates/macos-sign.yml` +# instead of called via `workflow_call`, because aiui signs and notarizes +# THREE artifacts in sequence (app → zip, dmg, updater bundle) and the +# DMG must be built from the already-stapled .app — a single +# `artifact-path` invocation in a separate job cannot express that. +# +# Manually triggered (workflow_dispatch) — never on push. macOS runners +# bill at 10× Linux; a release run is a deliberate, single-job spend. +# The version-sync check runs FIRST so a drifted dispatch dies in +# seconds, not after a 20-minute build (CI playbook: cheap before +# expensive). +# +# Required secrets (org- or repo-level on byte5ai/aiui): +# MACOS_CERTIFICATE_P12_BASE64 Developer ID Application .p12, base64 +# MACOS_CERTIFICATE_PASSWORD .p12 export password +# MACOS_NOTARY_KEY_ID App Store Connect API key ID +# MACOS_NOTARY_ISSUER_ID App Store Connect issuer ID +# MACOS_NOTARY_KEY_P8_BASE64 .p8 API private key, base64 +# TAURI_SIGNING_PRIVATE_KEY minisign private key (literal content) +# TAURI_SIGNING_PRIVATE_KEY_PASSWORD (empty string if none) +# UV_PUBLISH_TOKEN PyPI token, project-scoped to aiui-mcp +# +# The Windows installer is attached afterwards by release-windows.yml +# (also workflow_dispatch, against the release this workflow creates). + +name: Release (macOS) + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (e.g. 0.8.3) — must match Cargo.toml / tauri.conf.json / pyproject.toml" + required: true + type: string + prerelease: + description: "Mark as GitHub pre-release (updater clients skip it; promote later)" + required: false + type: boolean + default: false + publish-pypi: + description: "Publish aiui-mcp to PyPI (PyPI versions are permanent — disable for validate-first runs)" + required: false + type: boolean + default: true + +permissions: + contents: write # tag push + release creation + +concurrency: + group: release-macos + cancel-in-progress: false + +jobs: + release: + runs-on: macos-14 + env: + VERSION: ${{ inputs.version }} + TAG: v${{ inputs.version }} + steps: + - uses: actions/checkout@v4 + + # Four places must agree on the version BEFORE we spend build + # minutes: Cargo.toml (build), tauri.conf.json (Info.plist), + # pyproject.toml (PyPI artifact → what `uvx aiui-mcp` resolves on + # remote hosts), and the dispatched version (tag/release). Drift + # between them produced #82 (updater confusion) and the + # v0.4.2/v0.4.21 Tauri-vs-PyPI split on 2026-04-28. + - name: Check version sync + run: | + set -euo pipefail + grep -q "^version = \"${VERSION}\"" companion/src-tauri/Cargo.toml \ + || { echo "Cargo.toml does not match ${VERSION}"; exit 1; } + CONF="$(python3 -c 'import json;print(json.load(open("companion/src-tauri/tauri.conf.json"))["version"])')" + [ "$CONF" = "$VERSION" ] \ + || { echo "tauri.conf.json is ${CONF}, expected ${VERSION}"; exit 1; } + PYV="$(grep -E '^version = ' python/pyproject.toml | awk -F'"' '{print $2}')" + [ "$PYV" = "$VERSION" ] \ + || { echo "pyproject.toml is ${PYV}, expected ${VERSION}"; exit 1; } + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists — bump the version or delete the tag first." + exit 1 + fi + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: companion/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: companion/src-tauri -> target + key: release-aarch64-apple-darwin + + - uses: astral-sh/setup-uv@v3 + with: + version: latest + + # Ephemeral keychain + Developer ID import — from the macos-sign.yml + # template. The keychain password is generated per run and never a + # secret; the .p12 is removed as soon as it is imported. + - name: Import Developer ID certificate + env: + CERT_B64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + CERT_PW: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PW="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/cert.p12" + printf '%s' "$CERT_B64" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security import "$CERT_PATH" -P "$CERT_PW" -A -t cert -f pkcs12 \ + -k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PW" "$KEYCHAIN" >/dev/null + security list-keychains -d user -s "$KEYCHAIN" \ + $(security list-keychains -d user | sed 's/"//g') + IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | awk '/Developer ID Application/{print $2; exit}')" + [ -n "$IDENTITY" ] || { echo "No 'Developer ID Application' identity in the .p12"; exit 1; } + echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "SIGN_KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" + rm -f "$CERT_PATH" + + - name: Build frontend + working-directory: companion + run: npm ci && npm run build + + # APPLE_SIGNING_IDENTITY is picked up by the Tauri bundler (signs the + # .app during build); TAURI_SIGNING_PRIVATE_KEY produces the signed + # updater artifacts (createUpdaterArtifacts: true). + - name: Tauri build (aarch64, signed + updater artifacts) + working-directory: companion + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npx tauri build --target aarch64-apple-darwin + + # The in-app updater reads CFBundleShortVersionString to decide + # what's "current" — a drifted bundle reproduces #82 on the next + # update. Fail here, before notarization spends minutes. + - name: Verify bundled Info.plist version + run: | + set -euo pipefail + APP="companion/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/aiui.app" + PLIST_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" + [ "$PLIST_VERSION" = "$VERSION" ] \ + || { echo "Info.plist is ${PLIST_VERSION}, expected ${VERSION}"; exit 1; } + + - name: Codesign with entitlements + run: | + set -euo pipefail + APP="companion/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/aiui.app" + codesign --force --deep --options runtime \ + --keychain "$SIGN_KEYCHAIN" --sign "$APPLE_SIGNING_IDENTITY" \ + --entitlements companion/src-tauri/entitlements.plist \ + "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" + + - name: Write notary API key + env: + KEY_B64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }} + run: | + set -euo pipefail + printf '%s' "$KEY_B64" | base64 --decode > "$RUNNER_TEMP/notary.p8" + + - name: Notarize + staple app + env: + KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + APP="companion/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/aiui.app" + ZIP="aiui-${VERSION}-arm64.zip" + ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" + xcrun notarytool submit "$ZIP" \ + --key "$RUNNER_TEMP/notary.p8" --key-id "$KEY_ID" --issuer "$ISSUER_ID" --wait + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + # Re-create the distributable zip AFTER stapling so the ticket + # ships inside it. + rm -f "$ZIP" + ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" + + - name: Build, sign, notarize DMG + env: + KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + DMG="$PWD/aiui-${VERSION}-arm64.dmg" + rm -f "$DMG" + (cd companion && npx appdmg src-tauri/dmg/config.json "$DMG") + codesign --force --keychain "$SIGN_KEYCHAIN" \ + --sign "$APPLE_SIGNING_IDENTITY" "$DMG" + xcrun notarytool submit "$DMG" \ + --key "$RUNNER_TEMP/notary.p8" --key-id "$KEY_ID" --issuer "$ISSUER_ID" --wait + xcrun stapler staple "$DMG" + + # Updater bundle is re-created from the STAPLED app, then signed with + # the minisign key; tauri-updater expects the literal .sig content in + # latest.json. + - name: Build updater bundle + latest.json + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + APP_DIR="companion/src-tauri/target/aarch64-apple-darwin/release/bundle/macos" + BUNDLE="$APP_DIR/aiui.app.tar.gz" + rm -f "$BUNDLE" "$BUNDLE.sig" + tar -C "$APP_DIR" -czf "$BUNDLE" aiui.app + (cd companion && npx tauri signer sign "$GITHUB_WORKSPACE/$BUNDLE") >/dev/null + SIG_JSON="$(python3 -c 'import json,sys;print(json.dumps(open(sys.argv[1]).read()))' "$BUNDLE.sig")" + cp "$BUNDLE" "aiui-${VERSION}-updater-arm64.tar.gz" + PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + cat > latest.json < notes.md </dev/null || true diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 4c8684a..fa599dd 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -1,16 +1,17 @@ # Windows release pipeline for aiui — companion side only. # -# Why a separate workflow: macOS releases still run through -# `scripts/release.sh` locally (Apple-signing keys + notarytool live in -# the maintainer's keychain, not in CI). Windows has no such anchor — -# CI is the only practical place to produce a signed NSIS installer. +# Why a separate workflow: macOS releases run through +# `release-macos.yml` (workflow_dispatch; Developer ID + notarytool via +# App Store Connect API key, all in CI). Windows artifacts are attached +# in a second step because the Windows port is WIP and not every release +# ships one. # # This workflow is **manually triggered** (`workflow_dispatch`) against -# a tag the maintainer already created with `scripts/release.sh`. That -# script publishes the macOS GitHub release plus the initial -# `latest.json`; this workflow then attaches the Windows installer + -# updater bundle to the same release and rewrites `latest.json` to add -# the `windows-x86_64` platform entry. +# a tag/release that `release-macos.yml` already created. That workflow +# publishes the macOS GitHub release plus the initial `latest.json`; +# this workflow then attaches the Windows installer + updater bundle to +# the same release and rewrites `latest.json` to add the +# `windows-x86_64` platform entry. # # One-time setup (see also: scripts/release.sh header): # diff --git a/CHANGELOG.md b/CHANGELOG.md index 0846fd4..37865cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project are documented here. ### Added +- **macOS releases moved to CI (`release-macos.yml`).** The full release + pipeline — Tauri build, Developer-ID codesign, notarization + stapling + (App Store Connect API key), DMG, signed updater bundle + + `latest.json`, tag + GitHub release, PyPI publish — now runs as a + manually dispatched GitHub Actions workflow on a macOS runner, ported + from `scripts/release.sh`. The script stays as documented emergency + fallback; signing material lives in Actions secrets, not in a local + keychain. Inputs: `version` (sync-checked against all three manifests + before any build minute is spent), `prerelease` (validate-first flow), + `publish-pypi`. - **Forward-compat guard for the MCP 2026-07-28 spec.** The new stateless spec retires the `initialize` handshake; modern clients probe a stdio server with `server/discover` first and fall back to `initialize` on diff --git a/scripts/release.sh b/scripts/release.sh index a10f624..81bd6e8 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,4 +1,11 @@ #!/usr/bin/env bash +# ⚠️ LEGACY — releases run in CI now. Use the `Release (macOS)` workflow +# (.github/workflows/release-macos.yml, workflow_dispatch) instead of this +# script. It is the CI port of this file; signing + notarization happen on +# GitHub-hosted macOS runners via Developer ID + App Store Connect API key. +# This script stays as documented emergency fallback ONLY (e.g. GitHub +# Actions outage) and requires the local prerequisites below. +# # Build, sign, notarize, and publish an aiui release, including the updater # feed (latest.json) used by tauri-plugin-updater on running clients. #