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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:
run: swift --version
- name: Build
run: swift build
- name: Packaging smoke (unsigned; release signing is tag-only)
run: scripts/test-packaging.sh .build/debug/oab-instance-mcp
- name: Test
# OsascriptToolTests actually executes /usr/bin/osascript. Basic
# scripts don't need TCC grants, but headless-runner behavior is
Expand Down
176 changes: 176 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
name: Release macOS installer

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Existing v* tag to release (for retry only)"
required: true
type: string

# Release is the only workflow allowed to write repository contents. It never
# runs PR-controlled code with secrets: push tags and manual dispatch only.
permissions:
contents: write

concurrency:
group: instance-mcp-release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
name: Universal app + signed/notarized pkg
runs-on: macos-15
environment: release
env:
EXPECTED_TEAM_ID: 6LPQNY95AQ
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
APP_CERT_P12_B64: ${{ secrets.MACOS_APP_CERT_P12_BASE64 }}
APP_CERT_PASSWORD: ${{ secrets.MACOS_APP_CERT_PASSWORD }}
APP_SIGN_IDENTITY: ${{ secrets.MACOS_APP_SIGN_IDENTITY }}
INSTALLER_CERT_P12_B64: ${{ secrets.MACOS_INSTALLER_CERT_P12_BASE64 }}
INSTALLER_CERT_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_PASSWORD }}
INSTALLER_SIGN_IDENTITY: ${{ secrets.MACOS_INSTALLER_SIGN_IDENTITY }}
NOTARY_KEY_P8_B64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }}
NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ env.RELEASE_TAG }}
fetch-depth: 0

- name: Validate tag and release secrets
shell: bash
run: |
set -euo pipefail
case "$RELEASE_TAG" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "tag must be vMAJOR.MINOR.PATCH" >&2; exit 64;; esac
for var in APP_CERT_P12_B64 APP_CERT_PASSWORD APP_SIGN_IDENTITY \
INSTALLER_CERT_P12_B64 INSTALLER_CERT_PASSWORD INSTALLER_SIGN_IDENTITY \
NOTARY_KEY_P8_B64 NOTARY_KEY_ID NOTARY_ISSUER_ID; do
[ -n "${!var:-}" ] || { echo "missing release secret: $var" >&2; exit 78; }
done
case "$APP_SIGN_IDENTITY" in "Developer ID Application:"*) ;; *) echo "MACOS_APP_SIGN_IDENTITY must be Developer ID Application" >&2; exit 78;; esac
case "$INSTALLER_SIGN_IDENTITY" in "Developer ID Installer:"*) ;; *) echo "MACOS_INSTALLER_SIGN_IDENTITY must be Developer ID Installer" >&2; exit 78;; esac
VERSION=${RELEASE_TAG#v}
grep -q "let version = \"$VERSION\"" Sources/oab-instance-mcp/main.swift || {
echo "tag $RELEASE_TAG does not match the binary version source" >&2; exit 65; }
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "DIST=$RUNNER_TEMP/dist" >> "$GITHUB_ENV"

- name: Test
run: swift test --skip OsascriptToolTests

- name: Build universal release binary
shell: bash
run: |
set -euo pipefail
mkdir -p "$DIST"
swift build -c release --arch arm64 --scratch-path "$RUNNER_TEMP/build-arm64"
swift build -c release --arch x86_64 --scratch-path "$RUNNER_TEMP/build-x86_64"
/usr/bin/lipo -create \
"$RUNNER_TEMP/build-arm64/release/oab-instance-mcp" \
"$RUNNER_TEMP/build-x86_64/release/oab-instance-mcp" \
-output "$DIST/oab-instance-mcp"
chmod 755 "$DIST/oab-instance-mcp"
/usr/bin/lipo -info "$DIST/oab-instance-mcp" | grep -q 'x86_64 arm64\|arm64 x86_64'
[ "$($DIST/oab-instance-mcp --version)" = "$VERSION" ]
scripts/assemble-app.sh "$DIST/oab-instance-mcp" "$DIST/oab-instance-mcp.app" "$VERSION"

- name: Import Developer ID certificates
shell: bash
run: |
set -euo pipefail
KEYCHAIN="$RUNNER_TEMP/release-signing.keychain-db"
KEYCHAIN_PASSWORD=$(/usr/bin/openssl rand -hex 24)
echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
# Preserve the search list even though this job is pinned to a GitHub-
# hosted runner; restoring it keeps the workflow safe if moved later.
/usr/bin/security list-keychains -d user | \
/usr/bin/sed -E 's/^[[:space:]]*"(.*)"$/\1/' > "$RUNNER_TEMP/original-keychains.txt"
/usr/bin/security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
/usr/bin/security set-keychain-settings -lut 21600 "$KEYCHAIN"
/usr/bin/security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
printf '%s' "$APP_CERT_P12_B64" | /usr/bin/base64 -D > "$RUNNER_TEMP/app.p12"
printf '%s' "$INSTALLER_CERT_P12_B64" | /usr/bin/base64 -D > "$RUNNER_TEMP/installer.p12"
/usr/bin/security import "$RUNNER_TEMP/app.p12" -k "$KEYCHAIN" -P "$APP_CERT_PASSWORD" -T /usr/bin/codesign
/usr/bin/security import "$RUNNER_TEMP/installer.p12" -k "$KEYCHAIN" -P "$INSTALLER_CERT_PASSWORD" -T /usr/bin/pkgbuild
/usr/bin/security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
ORIGINAL=()
while IFS= read -r item; do [ -z "$item" ] || ORIGINAL+=("$item"); done < "$RUNNER_TEMP/original-keychains.txt"
/usr/bin/security list-keychains -d user -s "$KEYCHAIN" "${ORIGINAL[@]}"
/usr/bin/security find-identity -v "$KEYCHAIN"

- name: Sign and notarize app
shell: bash
run: |
set -euo pipefail
APP="$DIST/oab-instance-mcp.app"
/usr/bin/codesign --force --deep --options runtime --timestamp \
--keychain "$KEYCHAIN" --sign "$APP_SIGN_IDENTITY" "$APP"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP"
TEAM=$(/usr/bin/codesign -dvv "$APP" 2>&1 | /usr/bin/sed -n 's/^TeamIdentifier=//p')
[ "$TEAM" = "$EXPECTED_TEAM_ID" ] || { echo "wrong app team: $TEAM" >&2; exit 65; }
/usr/bin/ditto -c -k --keepParent "$APP" "$RUNNER_TEMP/app-for-notary.zip"
printf '%s' "$NOTARY_KEY_P8_B64" | /usr/bin/base64 -D > "$RUNNER_TEMP/AuthKey.p8"
xcrun notarytool submit "$RUNNER_TEMP/app-for-notary.zip" \
--key "$RUNNER_TEMP/AuthKey.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
rm -f "$DIST/oab-instance-mcp-$VERSION-universal.app.zip"
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP" \
"$DIST/oab-instance-mcp-$VERSION-universal.app.zip"

- name: Build, sign, and notarize installer pkg
shell: bash
run: |
set -euo pipefail
PKG="$DIST/oab-instance-mcp-$VERSION-universal.pkg"
EXPECT_TEAM="$EXPECTED_TEAM_ID" PKG_SIGN_IDENTITY="$INSTALLER_SIGN_IDENTITY" \
PKG_KEYCHAIN="$KEYCHAIN" \
scripts/package-pkg.sh "$DIST/oab-instance-mcp.app" "$PKG" "$VERSION"
/usr/sbin/pkgutil --check-signature "$PKG" | grep -q 'Developer ID Installer'
xcrun notarytool submit "$PKG" \
--key "$RUNNER_TEMP/AuthKey.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait
xcrun stapler staple "$PKG"
xcrun stapler validate "$PKG"
/usr/sbin/spctl -a -vv --type install "$PKG"
scripts/verify-release.sh \
"$DIST/oab-instance-mcp-$VERSION-universal.app.zip" "$PKG" "$EXPECTED_TEAM_ID"

- name: Checksums and release
shell: bash
run: |
set -euo pipefail
cd "$DIST"
/usr/bin/shasum -a 256 \
"oab-instance-mcp-$VERSION-universal.app.zip" \
"oab-instance-mcp-$VERSION-universal.pkg" > SHA256SUMS
gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$RELEASE_TAG" --verify-tag --generate-notes \
--title "oab-instance-mcp $VERSION"
gh release upload "$RELEASE_TAG" --clobber \
"oab-instance-mcp-$VERSION-universal.app.zip" \
"oab-instance-mcp-$VERSION-universal.pkg" SHA256SUMS

- name: Clean signing material
if: always()
shell: bash
run: |
rm -f "$RUNNER_TEMP/app.p12" "$RUNNER_TEMP/installer.p12" \
"$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/app-for-notary.zip"
if [ -f "$RUNNER_TEMP/original-keychains.txt" ]; then
ORIGINAL=()
while IFS= read -r item; do [ -z "$item" ] || ORIGINAL+=("$item"); done < "$RUNNER_TEMP/original-keychains.txt"
if [ "${#ORIGINAL[@]}" -gt 0 ]; then
/usr/bin/security list-keychains -d user -s "${ORIGINAL[@]}" || true
fi
rm -f "$RUNNER_TEMP/original-keychains.txt"
fi
if [ -n "${KEYCHAIN:-}" ] && [ -f "$KEYCHAIN" ]; then
/usr/bin/security delete-keychain "$KEYCHAIN" || true
fi
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,32 @@ Verified 2026-09-26 end to end on macmini against the openab-pty runtime (PR #38
`sys_info screenshot mouse key osascript instance_status`, `exec` refused, `sys_info` answered,
`DELETE /attach/{id}` detached.

## Download and install

Tagged releases publish a universal, Developer-ID-signed and Apple-notarized installer:

1. Download `oab-instance-mcp-VERSION-universal.pkg` from
[GitHub Releases](https://github.com/openabdev/instance-mcp/releases).
2. Sign into Tailscale and keep a desktop user logged in.
3. Double-click the package. It auto-detects your Tailscale login/name, preserves or creates the
bearer token, installs the LaunchAgent, detects the Playwright upstream, and configures
`tailscale serve :8444`.
4. Once, enable Full Disk Access, Screen & System Audio Recording, and Accessibility for
**oab-instance-mcp** in System Settings → Privacy & Security. Future releases keep the same
Developer ID + bundle id, so these grants survive updates.
5. Use the menu bar item to copy the MCP URL and bearer token into OpenAB Connect/Remote.

The `.app.zip` beside the package is an advanced/manual artifact. After unzipping:

```sh
/path/to/oab-instance-mcp.app/Contents/Resources/install-prebuilt.sh \
/path/to/oab-instance-mcp.app --allow-login auto
```

The installer never re-signs the app: doing so would change the identity TCC grants are bound to.
See [`docs/releasing.md`](docs/releasing.md) for artifacts, signing/notarization, required secrets,
local packaging smoke, and the current first-release signing blocker.

## TCC grants survive re-deploys only if the signature does

Screen Recording, Accessibility and Full Disk Access are keyed on the **code-signing identity +
Expand Down
1 change: 1 addition & 0 deletions Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ApplicationServices
import Foundation
import XCTest
@testable import InstanceMCPCore

Expand Down
127 changes: 127 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Releasing oab-instance-mcp for macOS

A release is a **universal (arm64 + x86_64), Developer-ID-signed and Apple-notarized** app plus a
signed/notarized installer package. The package is the normal download; the app zip is for advanced
users and inspection.

## Artifacts

A `vMAJOR.MINOR.PATCH` tag publishes:

| Artifact | Use |
|---|---|
| `oab-instance-mcp-VERSION-universal.pkg` | Recommended. Double-click; installs/configures the app for the logged-in desktop user |
| `oab-instance-mcp-VERSION-universal.app.zip` | Pre-signed app, no package receipt. Contains `Contents/Resources/install-prebuilt.sh` for manual installation |
| `SHA256SUMS` | SHA-256 of both artifacts |

The installer:

1. verifies bundle id, code signature and team `6LPQNY95AQ` **before** stopping the running service;
2. detects the current Tailscale login and MagicDNS name from structured `tailscale status --json`;
3. installs to `~/.local/oab-instance-mcp/oab-instance-mcp.app` without re-signing it;
4. creates a bearer token once at `~/.config/oab-instance-mcp/token`, mode 600, and preserves it on updates;
5. writes/starts the Aqua-user LaunchAgent `dev.openab.instance-mcp`;
6. adds the Playwright upstream when `dev.openab.instance-mcp.pw-mcp` is installed; and
7. configures `tailscale serve --https=8444` to loopback port 8795.

A logged-in GUI user and a logged-in Tailscale app are prerequisites. Package scripts run as root,
but immediately enter the console user's GUI bootstrap namespace and drop to that user; no token,
LaunchAgent or config is written to root's home.

## One-time TCC grant

After the first Developer-ID release install, enable **oab-instance-mcp** once under System Settings →
Privacy & Security:

- Full Disk Access
- Screen & System Audio Recording
- Accessibility

The first switch from the current Apple Development signature to Developer ID may require that one
re-grant. Every later release carries the same bundle id and Developer ID team, so the grant remains.
Neither the installer nor CI ever ad-hoc re-signs a release app. `install-prebuilt.sh` refuses a wrong
or missing TeamIdentifier before replacing the running app.

## Required GitHub environment and secrets

The workflow uses the `release` environment. Create it with required-reviewer protection if the repo
plan supports that, then add:

| Secret | Value |
|---|---|
| `MACOS_APP_CERT_P12_BASE64` | Base64 of the **Developer ID Application** certificate + private key `.p12` |
| `MACOS_APP_CERT_PASSWORD` | `.p12` export password |
| `MACOS_APP_SIGN_IDENTITY` | Exact common name, e.g. `Developer ID Application: Name (6LPQNY95AQ)` |
| `MACOS_INSTALLER_CERT_P12_BASE64` | Base64 of the **Developer ID Installer** certificate + private key `.p12` |
| `MACOS_INSTALLER_CERT_PASSWORD` | `.p12` export password |
| `MACOS_INSTALLER_SIGN_IDENTITY` | Exact common name, e.g. `Developer ID Installer: Name (6LPQNY95AQ)` |
| `APPLE_NOTARY_KEY_P8_BASE64` | Base64 of an App Store Connect API `.p8` key allowed to notarize |
| `APPLE_NOTARY_KEY_ID` | API key id |
| `APPLE_NOTARY_ISSUER_ID` | API issuer id |

**Use team `6LPQNY95AQ` only.** The machine still contains a deprecated team
`UM92U863A8` Developer ID Application certificate; it must never sign these releases. As of
2026-09-27 there is no Developer ID Application or Installer certificate for `6LPQNY95AQ` on the
build machines and the repository has no Actions secrets, so the first signed tag is intentionally
blocked until those assets are created and installed as secrets.

The tag workflow validates that both identity names are Developer ID identities, verifies the app's
TeamIdentifier is exactly `6LPQNY95AQ`, submits/staples both app and pkg, and runs signature/Gatekeeper
checks before creating the GitHub Release. Signing material lives in an ephemeral keychain and is
deleted in an `always()` cleanup step.

## Cut a release

1. Update `let version = "…"` in `Sources/oab-instance-mcp/main.swift` and merge with green CI.
2. Ensure the matching active-team release secrets above exist.
3. Tag the exact main commit and push:

```sh
git tag v0.7.0
git push origin v0.7.0
```

4. The `Release macOS installer` workflow builds/tests, signs, notarizes and publishes. A manual
dispatch can retry an existing tag; it is not a way to release an untagged commit.
5. Download both artifacts and verify before installing:

```sh
scripts/verify-release.sh \
oab-instance-mcp-0.7.0-universal.app.zip \
oab-instance-mcp-0.7.0-universal.pkg
shasum -a 256 -c SHA256SUMS
```

6. Install the `.pkg` on a clean/test Mac, verify `sys_info`, and make one reverse-attach call
before announcing it.

Never move or recreate a tag after an artifact has been published.

## Local/no-secret smoke

CI runs this on every PR:

```sh
swift build
scripts/test-packaging.sh .build/debug/oab-instance-mcp
```

It assembles the app, proves unsigned input is rejected by the production installer, exercises the
explicit unsigned test seam in a temporary home, verifies token persistence/LaunchAgent arguments,
builds an unsigned flat pkg, expands it, and checks its payload and postinstall scripts. It never
launches an agent or changes the user's Tailscale serve config.

To build a universal unsigned artifact manually on a Mac:

```sh
swift build -c release --arch arm64 --scratch-path /tmp/imcp-arm64
swift build -c release --arch x86_64 --scratch-path /tmp/imcp-x86_64
lipo -create /tmp/imcp-arm64/release/oab-instance-mcp \
/tmp/imcp-x86_64/release/oab-instance-mcp \
-output /tmp/oab-instance-mcp
chmod +x /tmp/oab-instance-mcp
scripts/assemble-app.sh /tmp/oab-instance-mcp /tmp/oab-instance-mcp.app 0.6.0
ALLOW_UNSIGNED=1 scripts/package-pkg.sh /tmp/oab-instance-mcp.app /tmp/oab-instance-mcp.pkg 0.6.0
```

Unsigned artifacts are testing inputs only; do not install or publish them.
Loading
Loading