From 6d60a25878d006be90688ea3635f28f3fdd3e2fc Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Thu, 13 Aug 2026 00:10:41 +0530 Subject: [PATCH 1/3] feat(macos): add signed universal distribution --- .github/scripts/macos-package.sh | 79 ++++++++++++ .github/workflows/release.yml | 91 ++++++++++++-- CHANGELOG.md | 3 + CONTRIBUTING.md | 6 + README.md | 18 ++- apps/headless/Tests/macos-distribution.sh | 92 ++++++++++++++ apps/headless/build.sh | 147 ++++++++++++++++++---- apps/headless/docs/P3.md | 66 ++++++++++ apps/headless/headless.entitlements | 5 +- docs/ROADMAP.md | 16 ++- docs/roadmap/architecture-decisions.md | 28 +++++ docs/roadmap/improvements-backlog.md | 12 +- 12 files changed, 511 insertions(+), 52 deletions(-) create mode 100755 .github/scripts/macos-package.sh create mode 100755 apps/headless/Tests/macos-distribution.sh create mode 100644 apps/headless/docs/P3.md diff --git a/.github/scripts/macos-package.sh b/.github/scripts/macos-package.sh new file mode 100755 index 0000000..2be5e6e --- /dev/null +++ b/.github/scripts/macos-package.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then + echo "usage: macos-package.sh APP VERSION OUTPUT_DIRECTORY [--notarize]" >&2 + exit 64 +fi + +APP="$1" +VERSION="$2" +OUTPUT_DIRECTORY="$3" +MODE="${4:-}" +SCRIPT_DIRECTORY="$(CDPATH='' cd -- "$(dirname "$0")" && pwd -P)" +REPOSITORY_ROOT="$(CDPATH='' cd -- "$SCRIPT_DIRECTORY/../.." && pwd -P)" + +[ -d "$APP" ] || { echo "macOS package: app does not exist: $APP" >&2; exit 66; } +SEMVER_PATTERN="$(cat "$REPOSITORY_ROOT/apps/headless/VersionSupport/semver-pattern.txt")" +printf '%s\n' "$VERSION" | grep -Eq "$SEMVER_PATTERN" || { + echo "macOS package: invalid semantic version: $VERSION" >&2 + exit 64 +} +case "$MODE" in + ""|--notarize) ;; + *) echo "macOS package: unknown option: $MODE" >&2; exit 64 ;; +esac + +mkdir -p "$OUTPUT_DIRECTORY" +OUTPUT_DIRECTORY="$(CDPATH='' cd -- "$OUTPUT_DIRECTORY" && pwd -P)" +APP="$(CDPATH='' cd -- "$(dirname "$APP")" && pwd -P)/$(basename "$APP")" +ARCHIVE="$OUTPUT_DIRECTORY/Headless-${VERSION}-macos.zip" + +package_app() { + ditto -c -k --keepParent "$APP" "$ARCHIVE" + test -s "$ARCHIVE" + unzip -t "$ARCHIVE" >/dev/null + unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/MacOS/Headless' + unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless' + unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless-mcp' +} + +if [ "$MODE" = "--notarize" ]; then + : "${APPLE_NOTARY_KEY_PATH:?macOS package: APPLE_NOTARY_KEY_PATH is required}" + : "${APPLE_NOTARY_KEY_ID:?macOS package: APPLE_NOTARY_KEY_ID is required}" + : "${APPLE_NOTARY_ISSUER_ID:?macOS package: APPLE_NOTARY_ISSUER_ID is required}" + [ -f "$APPLE_NOTARY_KEY_PATH" ] || { + echo "macOS package: notary API key does not exist: $APPLE_NOTARY_KEY_PATH" >&2 + exit 66 + } + for tool in codesign plutil spctl xcrun; do + command -v "$tool" >/dev/null 2>&1 || { echo "macOS package: $tool is required" >&2; exit 69; } + done + "$REPOSITORY_ROOT/apps/headless/Tests/macos-distribution.sh" \ + "$APP" "$VERSION" developer-id "arm64 x86_64" +fi + +package_app + +if [ "$MODE" = "--notarize" ]; then + NOTARY_RESPONSE="$(xcrun notarytool submit "$ARCHIVE" \ + --key "$APPLE_NOTARY_KEY_PATH" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_ISSUER_ID" \ + --wait \ + --output-format json)" + printf '%s\n' "$NOTARY_RESPONSE" + NOTARY_STATUS="$(printf '%s\n' "$NOTARY_RESPONSE" | plutil -extract status raw -o - -)" + [ "$NOTARY_STATUS" = "Accepted" ] || { + echo "macOS package: Apple rejected the notarization submission ($NOTARY_STATUS)" >&2 + exit 1 + } + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + "$REPOSITORY_ROOT/apps/headless/Tests/macos-distribution.sh" \ + "$APP" "$VERSION" developer-id "arm64 x86_64" + spctl --assess --type execute --verbose=4 "$APP" + package_app +fi + +echo "macOS package created: $ARCHIVE" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46ec031..b50112f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ on: - "apps/headless/install-linux.sh" - "apps/headless/install.sh" - "apps/headless/Tests/linux-installer.sh" + - "apps/headless/Tests/macos-distribution.sh" + - "apps/headless/headless.entitlements" - "apps/headless/Package.swift" - "apps/headless/Sources/HeadlessProtocol/ProductVersion.swift" - "apps/headless/VERSION" @@ -75,8 +77,41 @@ jobs: - uses: actions/checkout@v7 - name: Install FFmpeg run: brew install ffmpeg + - name: Import Developer ID certificate + if: needs.version.outputs.publish == 'true' + id: signing + shell: bash + env: + CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + [[ -n "$CERTIFICATE_BASE64" && -n "$CERTIFICATE_PASSWORD" ]] || { + echo "Apple Developer ID certificate secrets are required for tagged releases" >&2 + exit 64 + } + keychain="$RUNNER_TEMP/headless-signing.keychain-db" + certificate="$RUNNER_TEMP/headless-developer-id.p12" + keychain_password="$(uuidgen)$(uuidgen)" + printf '%s' "$CERTIFICATE_BASE64" | base64 -D > "$certificate" + chmod 0600 "$certificate" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate" -k "$keychain" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" + identities="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p')" + [[ "$(printf '%s\n' "$identities" | sed '/^$/d' | wc -l | tr -d ' ')" == 1 ]] || { + echo "The certificate archive must contain exactly one Developer ID Application identity" >&2 + exit 64 + } + echo "identity=$identities" >> "$GITHUB_OUTPUT" - name: Build env: + CODESIGN_IDENTITY: ${{ steps.signing.outputs.identity }} + HEADLESS_ARCHS: arm64 x86_64 + HEADLESS_RELEASE_BUILD: ${{ needs.version.outputs.publish == 'true' && '1' || '0' }} HEADLESS_VERSION: ${{ needs.version.outputs.version }} run: ./apps/headless/build.sh - name: Unit tests @@ -85,18 +120,54 @@ jobs: run: ./apps/headless/test.sh - name: E2E run: zsh ./apps/headless/Tests/macos-e2e.sh + - name: Validate distribution bundle + shell: bash + env: + PUBLISH: ${{ needs.version.outputs.publish }} + VERSION: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + mode=adhoc + if [[ "$PUBLISH" == true ]]; then mode=developer-id; fi + ./apps/headless/Tests/macos-distribution.sh \ + apps/headless/Headless.app "$VERSION" "$mode" "arm64 x86_64" + - name: Prepare notarization key + if: needs.version.outputs.publish == 'true' + shell: bash + env: + NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + [[ -n "$NOTARY_KEY_BASE64" && -n "$NOTARY_KEY_ID" && -n "$NOTARY_ISSUER_ID" ]] || { + echo "Apple notary API secrets are required for tagged releases" >&2 + exit 64 + } + printf '%s' "$NOTARY_KEY_BASE64" | base64 -D > "$RUNNER_TEMP/AuthKey.p8" + chmod 0600 "$RUNNER_TEMP/AuthKey.p8" - name: Package env: + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 + PUBLISH: ${{ needs.version.outputs.publish }} VERSION: ${{ needs.version.outputs.version }} run: | - set -eu - cd apps/headless - ditto -c -k --keepParent Headless.app "Headless-${VERSION}-macos.zip" - test -s "Headless-${VERSION}-macos.zip" - unzip -t "Headless-${VERSION}-macos.zip" - unzip -Z1 "Headless-${VERSION}-macos.zip" | grep -qx 'Headless.app/Contents/MacOS/Headless' - unzip -Z1 "Headless-${VERSION}-macos.zip" | grep -qx 'Headless.app/Contents/Resources/bin/headless' - unzip -Z1 "Headless-${VERSION}-macos.zip" | grep -qx 'Headless.app/Contents/Resources/bin/headless-mcp' + set -euo pipefail + options=() + if [[ "$PUBLISH" == true ]]; then options+=(--notarize); fi + ./.github/scripts/macos-package.sh \ + apps/headless/Headless.app "$VERSION" apps/headless "${options[@]}" + - name: Clean signing material + if: always() && needs.version.outputs.publish == 'true' + shell: bash + env: + KEYCHAIN: ${{ runner.temp }}/headless-signing.keychain-db + run: | + set -euo pipefail + if [[ -f "$KEYCHAIN" ]]; then security delete-keychain "$KEYCHAIN" || true; fi + rm -f "$RUNNER_TEMP/headless-developer-id.p12" "$RUNNER_TEMP/AuthKey.p8" - uses: actions/upload-artifact@v7 with: name: macos @@ -269,7 +340,7 @@ jobs: | File | Platform | | --- | --- | - | `Headless-${{ needs.version.outputs.version }}-macos.zip` | macOS (ad-hoc signed `.app`) | + | `Headless-${{ needs.version.outputs.version }}-macos.zip` | macOS universal, Developer ID signed and notarized | | `headless-${{ needs.version.outputs.version }}-linux-amd64.tar.gz` | Linux x86_64 | | `headless-${{ needs.version.outputs.version }}-linux-arm64.tar.gz` | Linux arm64 | | `install.sh` | Verified Linux bootstrap installer | @@ -277,7 +348,7 @@ jobs: ### Notes - - **macOS:** Unzip and run `Headless.app`. Gatekeeper may warn (ad-hoc signature; notarization not included yet). CLI: `Headless.app/Contents/Resources/bin/headless`. + - **macOS:** Unzip and run `Headless.app`, or install with `brew install --cask LockInTime/headless/headless` after the tap sync completes. The app is universal, Developer ID signed, notarized, and stapled. CLI: `Headless.app/Contents/Resources/bin/headless`. - **Linux:** `curl -fsSL https://github.com/LockInTime/headless/releases/latest/download/install.sh | sh`. The installer verifies the selected tarball against `SHA256SUMS`. Chromium and FFmpeg remain system dependencies. - **Container:** `docker pull ${{ env.CONTAINER_IMAGE }}:${{ needs.version.outputs.version }}`. The image includes Chromium and FFmpeg and runs as uid 10001. files: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 952c608..30677d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ Cutting that release is tracked in ### Added +- Tagged macOS releases now ship a universal Apple Silicon/Intel app through a + checksum-pinned Homebrew cask, with Developer ID signing, hardened runtime, + notarization, stapling, and Gatekeeper validation enforced by release CI. - Tagged releases now publish a smoke-tested, non-root amd64/arm64 production image to GHCR with SemVer and commit-SHA tags, provenance, and an SBOM. - A checksum-verifying Linux bootstrap installer now selects the correct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd80e6f..4539e98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,12 @@ of GHCR and requires an anonymous pull by digest before it creates the GitHub Release, so a private image fails closed instead of producing a broken public release. +macOS release signing, notarization, and Homebrew publication follow the +operator contract in [`apps/headless/docs/P3.md`](apps/headless/docs/P3.md). +Never replace missing Apple credentials with ad-hoc signing on a tag, and never +grant the product release workflow cross-repository write access. The tap +updates itself with its own repository-scoped workflow token. + ## Style - **Swift:** Foundation and system frameworks only. No new SwiftPM diff --git a/README.md b/README.md index f624c01..3f39bce 100644 --- a/README.md +++ b/README.md @@ -170,13 +170,25 @@ accessible name; screenshots and recordings capture the visible media pixels. ### macOS +```sh +brew install --cask LockInTime/headless/headless +headless help +``` + +Beginning with the next signed release, macOS bundles are universal for Apple +Silicon and Intel, Developer ID signed, notarized, and stapled. The tap syncs +after publication, and the cask also links `headless-mcp`. To build locally +instead: + ```sh ./apps/headless/build.sh ./apps/headless/Headless.app/Contents/Resources/bin/headless help ``` Requires Xcode Command Line Tools. The build checks for Swift, Apple utilities, -and a compatible SDK before compiling. +and a compatible SDK before compiling. Local builds target the current +architecture by default; set `HEADLESS_ARCHS="arm64 x86_64"` to reproduce the +universal release bundle. ### Linux @@ -263,8 +275,8 @@ sha256sum --ignore-missing -c SHA256SUMS # Linux shasum -a 256 --ignore-missing -c SHA256SUMS # macOS ``` -See the Actions `Release` workflow and the release notes on each tag for -install caveats (Gatekeeper; Linux Chromium/FFmpeg). +See the Actions `Release` workflow and the release notes on each tag for Linux +Chromium/FFmpeg install caveats. The `Release` workflow can also be run manually with `dry_run` enabled. That builds, verifies, and uploads all three workflow artifacts without creating a diff --git a/apps/headless/Tests/macos-distribution.sh b/apps/headless/Tests/macos-distribution.sh new file mode 100755 index 0000000..2c5a66f --- /dev/null +++ b/apps/headless/Tests/macos-distribution.sh @@ -0,0 +1,92 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "usage: macos-distribution.sh APP VERSION SIGNATURE_MODE ARCHITECTURES" >&2 + exit 64 +fi + +APP="$1" +EXPECTED_VERSION="$2" +SIGNATURE_MODE="$3" +EXPECTED_ARCHITECTURES="$4" + +fail() { + echo "macOS distribution: $1" >&2 + exit 1 +} + +for tool in codesign file lipo plutil; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is required" +done + +[ -d "$APP" ] || fail "app bundle does not exist: $APP" +PLIST="$APP/Contents/Info.plist" +[ -f "$PLIST" ] || fail "Info.plist is missing" +ACTUAL_VERSION="$(plutil -extract CFBundleShortVersionString raw "$PLIST")" +[ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ] || fail "expected version $EXPECTED_VERSION, received $ACTUAL_VERSION" + +EXECUTABLES=" +$APP/Contents/MacOS/Headless +$APP/Contents/Resources/bin/headless +$APP/Contents/Resources/bin/headless-mcp +" +printf '%s\n' "$EXECUTABLES" | while IFS= read -r executable; do + [ -n "$executable" ] || continue + [ -x "$executable" ] || fail "missing executable: $executable" + file "$executable" | grep -q 'Mach-O' || fail "not a Mach-O executable: $executable" + ACTUAL_ARCHITECTURES="$(lipo -archs "$executable")" + for architecture in $EXPECTED_ARCHITECTURES; do + printf '%s\n' "$ACTUAL_ARCHITECTURES" | grep -Eq "(^| )$architecture( |$)" \ + || fail "$executable is missing $architecture" + done + for architecture in $ACTUAL_ARCHITECTURES; do + printf '%s\n' "$EXPECTED_ARCHITECTURES" | grep -Eq "(^| )$architecture( |$)" \ + || fail "$executable contains unexpected architecture $architecture" + done + codesign --verify --strict "$executable" || fail "code signature verification failed: $executable" + EXECUTABLE_SIGNATURE="$(codesign --display --verbose=4 "$executable" 2>&1)" + case "$SIGNATURE_MODE" in + adhoc) + printf '%s\n' "$EXECUTABLE_SIGNATURE" | grep -q '^Signature=adhoc$' \ + || fail "expected an ad-hoc signature: $executable" + ;; + developer-id) + printf '%s\n' "$EXECUTABLE_SIGNATURE" | grep -q '^Authority=Developer ID Application:' \ + || fail "expected a Developer ID Application signature: $executable" + printf '%s\n' "$EXECUTABLE_SIGNATURE" | grep -q 'flags=.*runtime' \ + || fail "hardened runtime is not enabled: $executable" + printf '%s\n' "$EXECUTABLE_SIGNATURE" | grep -q '^Timestamp=' \ + || fail "secure timestamp is missing: $executable" + ;; + esac +done + +codesign --verify --deep --strict "$APP" || fail "code signature verification failed" +SIGNATURE_DETAILS="$(codesign --display --verbose=4 "$APP" 2>&1)" +ENTITLEMENTS="$(codesign --display --entitlements :- "$APP" 2>/dev/null || true)" +printf '%s\n' "$ENTITLEMENTS" | grep -q 'com.apple.security.get-task-allow' \ + && fail "get-task-allow is forbidden in a distribution build" + +case "$SIGNATURE_MODE" in + adhoc) + printf '%s\n' "$SIGNATURE_DETAILS" | grep -q '^Signature=adhoc$' \ + || fail "expected an ad-hoc signature" + ;; + developer-id) + printf '%s\n' "$SIGNATURE_DETAILS" | grep -q '^Authority=Developer ID Application:' \ + || fail "expected a Developer ID Application signature" + printf '%s\n' "$SIGNATURE_DETAILS" | grep -q 'flags=.*runtime' \ + || fail "hardened runtime is not enabled" + printf '%s\n' "$SIGNATURE_DETAILS" | grep -q '^Timestamp=' \ + || fail "secure timestamp is missing" + ;; + *) fail "unknown signature mode: $SIGNATURE_MODE" ;; +esac + +if printf '%s\n' "$ENTITLEMENTS" | grep -q 'com.apple.developer.web-browser.public-key-credential'; then + [ -f "$APP/Contents/embedded.provisionprofile" ] \ + || fail "passkey entitlement requires an embedded provisioning profile" +fi + +echo "macOS distribution validation passed ($SIGNATURE_MODE; $EXPECTED_ARCHITECTURES)" diff --git a/apps/headless/build.sh b/apps/headless/build.sh index 5b21f6f..c6f3bff 100755 --- a/apps/headless/build.sh +++ b/apps/headless/build.sh @@ -3,7 +3,7 @@ set -euo pipefail cd "${0:a:h}" -for tool in swift swiftc iconutil codesign; do +for tool in swift swiftc iconutil codesign lipo security; do if ! command -v "$tool" >/dev/null 2>&1; then echo "headless build: missing $tool. Install Xcode Command Line Tools with: xcode-select --install" >&2 exit 69 @@ -11,11 +11,46 @@ for tool in swift swiftc iconutil codesign; do done APP="Headless.app" -ARCH="$(uname -m)" +NATIVE_ARCH="$(uname -m)" +ARCH_LIST="${HEADLESS_ARCHS:-$NATIVE_ARCH}" +ARCHS=(${=ARCH_LIST}) ICON="build/Headless.icns" VERSION="${HEADLESS_VERSION:-$(tr -d '[:space:]' < VERSION)}" +RELEASE_BUILD="${HEADLESS_RELEASE_BUILD:-0}" mkdir -p build/module-cache build/swiftpm-module-cache build/bin +if [[ "$RELEASE_BUILD" != "0" && "$RELEASE_BUILD" != "1" ]]; then + echo "headless build: HEADLESS_RELEASE_BUILD must be 0 or 1" >&2 + exit 64 +fi +if [[ "$RELEASE_BUILD" == "1" ]]; then + if [[ -z "${CODESIGN_IDENTITY:-}" ]]; then + echo "headless build: release builds require CODESIGN_IDENTITY" >&2 + exit 64 + fi + if [[ "$CODESIGN_IDENTITY" != Developer\ ID\ Application:* ]]; then + echo "headless build: release builds require a Developer ID Application identity" >&2 + exit 64 + fi +fi + +if (( ${#ARCHS[@]} == 0 )); then + echo "headless build: HEADLESS_ARCHS must include arm64, x86_64, or both" >&2 + exit 64 +fi +typeset -A SEEN_ARCHS +for arch in "${ARCHS[@]}"; do + if [[ "$arch" != "arm64" && "$arch" != "x86_64" ]]; then + echo "headless build: unsupported architecture: $arch" >&2 + exit 64 + fi + if [[ -n "${SEEN_ARCHS[$arch]:-}" ]]; then + echo "headless build: duplicate architecture: $arch" >&2 + exit 64 + fi + SEEN_ARCHS[$arch]=1 +done + # Select an SDK the installed Swift compiler can read. Apple occasionally ships # a Command Line Tools compiler update before changing the default SDK symlink. SDK_ARGS=() @@ -23,7 +58,7 @@ if [[ -z "${SDKROOT:-}" ]]; then COMPATIBLE_SDK="" for sdk in /Library/Developer/CommandLineTools/SDKs/MacOSX*.sdk(NOn); do if swiftc -module-cache-path build/module-cache -sdk "$sdk" \ - -target "$ARCH-apple-macos13.0" -typecheck \ + -target "$NATIVE_ARCH-apple-macos13.0" -typecheck \ Sources/HeadlessProtocol/Protocol.swift \ Sources/HeadlessProtocol/HostError.swift \ Sources/HeadlessProtocol/CaptureFormats.swift >/dev/null 2>&1; then @@ -50,27 +85,50 @@ if [[ ! -f "$ICON" ]]; then iconutil -c icns build/AppIcon.iconset -o "$ICON" fi -echo "▸ compiling ($ARCH)" +echo "▸ compiling (${(j:,:)ARCHS})" +rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" SWIFT_SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/headless-build.XXXXXX")" trap 'rm -rf "$SWIFT_SCRATCH"' EXIT -BIN_PATH="$(swift build "${SDK_ARGS[@]}" -c release --scratch-path "$SWIFT_SCRATCH" --show-bin-path)" -swift build "${SDK_ARGS[@]}" -c release --product headless-host --scratch-path "$SWIFT_SCRATCH" -swift build "${SDK_ARGS[@]}" -c release --product headless --scratch-path "$SWIFT_SCRATCH" -swift build "${SDK_ARGS[@]}" -c release --product headless-mcp --scratch-path "$SWIFT_SCRATCH" -RESOURCE_BUNDLE="$BIN_PATH/Headless_HeadlessProtocol.bundle" +HOST_BINARIES=() +CLI_BINARIES=() +MCP_BINARIES=() +RESOURCE_BUNDLE="" +for arch in "${ARCHS[@]}"; do + ARCH_SCRATCH="$SWIFT_SCRATCH/$arch" + TARGET_ARGS=(--triple "$arch-apple-macos13.0") + BIN_PATH="$(swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --scratch-path "$ARCH_SCRATCH" --show-bin-path)" + swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --product headless-host --scratch-path "$ARCH_SCRATCH" + swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --product headless --scratch-path "$ARCH_SCRATCH" + swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --product headless-mcp --scratch-path "$ARCH_SCRATCH" + HOST_BINARIES+=("$BIN_PATH/headless-host") + CLI_BINARIES+=("$BIN_PATH/headless") + MCP_BINARIES+=("$BIN_PATH/headless-mcp") + if [[ -z "$RESOURCE_BUNDLE" ]]; then + RESOURCE_BUNDLE="$BIN_PATH/Headless_HeadlessProtocol.bundle" + fi +done if [[ ! -d "$RESOURCE_BUNDLE" ]]; then echo "headless build: compiled HeadlessProtocol resource bundle was not found" >&2 exit 70 fi -cp "$BIN_PATH/headless-host" "$APP/Contents/MacOS/Headless" + +copy_or_merge() { + local destination="$1" + shift + if (( $# == 1 )); then + cp "$1" "$destination" + else + lipo -create "$@" -output "$destination" + fi +} + +copy_or_merge "$APP/Contents/MacOS/Headless" "${HOST_BINARIES[@]}" mkdir -p "$APP/Contents/Resources/bin" -cp "$BIN_PATH/headless" "$APP/Contents/Resources/bin/headless" -cp "$BIN_PATH/headless" build/bin/headless -cp "$BIN_PATH/headless-mcp" "$APP/Contents/Resources/bin/headless-mcp" -cp "$BIN_PATH/headless-mcp" build/bin/headless-mcp -rm -rf "$APP/Headless_HeadlessProtocol.bundle" -rm -rf "$APP/Contents/Resources/Headless_HeadlessProtocol.bundle" +copy_or_merge "$APP/Contents/Resources/bin/headless" "${CLI_BINARIES[@]}" +copy_or_merge "$APP/Contents/Resources/bin/headless-mcp" "${MCP_BINARIES[@]}" +cp "$APP/Contents/Resources/bin/headless" build/bin/headless +cp "$APP/Contents/Resources/bin/headless-mcp" build/bin/headless-mcp cp -R "$RESOURCE_BUNDLE" "$APP/Contents/Resources/Headless_HeadlessProtocol.bundle" cp "$ICON" "$APP/Contents/Resources/Headless.icns" @@ -103,20 +161,57 @@ PLIST Tests/macos-bundle-security.sh "$APP/Contents/Info.plist" # Passkeys require Apple's restricted web-browser.public-key-credential -# entitlement backed by a provisioning profile; macOS SIGKILLs ad-hoc builds -# that claim it. Default: ad-hoc, no entitlement (app hides WebAuthn so sites -# offer fallback sign-in). Once Apple grants the capability to your App ID: -# PROVISIONING_PROFILE=headless.provisionprofile \ -# CODESIGN_IDENTITY="Apple Development: you@example.com (TEAMID)" ./build.sh +# entitlement backed by an Apple-approved provisioning profile. Direct +# Developer ID distribution does not receive that capability by default, so +# release builds omit it and the app exposes its existing fallback sign-in +# behavior. If Apple approves com.headless.app, provide both files explicitly. if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then - if [[ -n "${PROVISIONING_PROFILE:-}" ]]; then + SIGN_ARGS=(--force --sign "$CODESIGN_IDENTITY") + if [[ "$RELEASE_BUILD" == "1" ]]; then + SIGN_ARGS+=(--options runtime --timestamp) + fi + APP_ENTITLEMENT_ARGS=() + if [[ -n "${PROVISIONING_PROFILE:-}" || -n "${PASSKEY_ENTITLEMENTS:-}" ]]; then + if [[ -z "${PROVISIONING_PROFILE:-}" || -z "${PASSKEY_ENTITLEMENTS:-}" ]]; then + echo "headless build: passkeys require both PROVISIONING_PROFILE and PASSKEY_ENTITLEMENTS" >&2 + exit 64 + fi + if [[ ! -f "$PROVISIONING_PROFILE" || ! -f "$PASSKEY_ENTITLEMENTS" ]]; then + echo "headless build: passkey provisioning inputs must be regular files" >&2 + exit 66 + fi + PROFILE_PLIST="$SWIFT_SCRATCH/provisioning-profile.plist" + security cms -D -i "$PROVISIONING_PROFILE" > "$PROFILE_PLIST" || { + echo "headless build: provisioning profile could not be decoded" >&2 + exit 65 + } + PROFILE_APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$PROFILE_PLIST" 2>/dev/null || true)" + case "$PROFILE_APP_ID" in + *.com.headless.app) ;; + *) + echo "headless build: provisioning profile is not approved for com.headless.app" >&2 + exit 64 + ;; + esac + PROFILE_PASSKEY="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:com.apple.developer.web-browser.public-key-credential' "$PROFILE_PLIST" 2>/dev/null || true)" + REQUESTED_PASSKEY="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.developer.web-browser.public-key-credential' "$PASSKEY_ENTITLEMENTS" 2>/dev/null || true)" + if [[ "$PROFILE_PASSKEY" != true || "$REQUESTED_PASSKEY" != true ]]; then + echo "headless build: Apple-approved passkey entitlement is missing from the provisioning inputs" >&2 + exit 64 + fi cp "$PROVISIONING_PROFILE" "$APP/Contents/embedded.provisionprofile" + APP_ENTITLEMENT_ARGS=(--entitlements "$PASSKEY_ENTITLEMENTS") fi - codesign --force --sign "$CODESIGN_IDENTITY" "$APP/Contents/Resources/bin/headless" - codesign --force --sign "$CODESIGN_IDENTITY" "$APP/Contents/Resources/bin/headless-mcp" - codesign --force --sign "$CODESIGN_IDENTITY" --entitlements headless.entitlements "$APP" - echo "▸ signed as $CODESIGN_IDENTITY with passkey entitlement" + codesign "${SIGN_ARGS[@]}" "$APP/Contents/Resources/bin/headless" + codesign "${SIGN_ARGS[@]}" "$APP/Contents/Resources/bin/headless-mcp" + codesign "${SIGN_ARGS[@]}" "${APP_ENTITLEMENT_ARGS[@]}" "$APP" + codesign --verify --deep --strict "$APP" + echo "▸ signed as $CODESIGN_IDENTITY" else + if [[ -n "${PROVISIONING_PROFILE:-}" || -n "${PASSKEY_ENTITLEMENTS:-}" ]]; then + echo "headless build: passkey provisioning requires CODESIGN_IDENTITY" >&2 + exit 64 + fi codesign --force --sign - "$APP/Contents/Resources/bin/headless" 2>/dev/null codesign --force --sign - "$APP/Contents/Resources/bin/headless-mcp" 2>/dev/null codesign --force --sign - "$APP" 2>/dev/null diff --git a/apps/headless/docs/P3.md b/apps/headless/docs/P3.md new file mode 100644 index 0000000..9e0a0fb --- /dev/null +++ b/apps/headless/docs/P3.md @@ -0,0 +1,66 @@ +# P3 distribution contract + +P3 makes the existing macOS and Linux products installable without weakening +their runtime boundaries. Distribution automation packages the same binaries +and runs the same protocol and real-engine tests used during development. + +## macOS + +Tagged releases build `Headless.app` as a universal `arm64` and `x86_64` +bundle. The app host, CLI, and MCP adapter must all contain both architectures. +The release job signs every executable with one Developer ID Application +identity, hardened runtime, and a secure timestamp. It then submits the ZIP to +Apple with `notarytool`, staples the accepted ticket to the app, validates the +ticket, reruns strict code-signature and Gatekeeper assessment, and recreates +the downloadable ZIP from the stapled app. + +Release signing is fail closed. A tag cannot fall back to an ad-hoc signature +when a certificate or notary secret is missing. Pull requests run the same +universal build, package, and structural checks with an ad-hoc signature but +never contact Apple's service. + +WKWebView passkeys require the restricted +`com.apple.developer.web-browser.public-key-credential` entitlement. Developer +ID distribution does not receive that capability by default. Headless omits it +from normal releases and retains the host's feature-detection fallback so sites +offer another sign-in method. The build accepts the entitlement only when an +Apple-approved provisioning profile and entitlement file are both provided +explicitly for `com.headless.app`. + +The checksum-pinned Homebrew cask installs the app plus `headless` and +`headless-mcp` command links from `LockInTime/homebrew-headless`. The tap polls +published releases and updates itself with its own repository-scoped +`GITHUB_TOKEN`; the product release workflow receives no cross-repository write +credential. + +## Linux and containers + +The native bootstrap selects amd64 or arm64, verifies the release checksum and +archive shape, and runs the packaged Chromium/FFmpeg preflight before copying +binaries. The GHCR production image contains both architectures, runs as uid +10001, exposes no ports, and must be anonymously pullable by digest before the +GitHub release is created. + +## Release operator secrets + +Tagged macOS releases require these GitHub Actions secrets: + +- `APPLE_DEVELOPER_ID_CERTIFICATE_BASE64`: base64 PKCS#12 containing exactly + one Developer ID Application identity. +- `APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD`: PKCS#12 password. +- `APPLE_NOTARY_KEY_BASE64`: base64 App Store Connect API private key. +- `APPLE_NOTARY_KEY_ID` and `APPLE_NOTARY_ISSUER_ID`: notary API identifiers. + +The Developer ID certificate and notary key must belong to the same Apple +Developer team responsible for `com.headless.app`. Secrets are written only to +the hosted runner's temporary directory or temporary keychain and are not +uploaded as artifacts. + +## Verification + +Before a tag is published, the release graph requires protocol/security tests, +real WKWebView and Chromium E2E, universal architecture checks, strict signing +checks, notarization and stapling, package checksums, native and multi-platform +container smoke tests, and Homebrew cask input validation. The tap independently +verifies the published `SHA256SUMS` entry and runs Homebrew style validation +before committing a cask. No check uses `continue-on-error`. diff --git a/apps/headless/headless.entitlements b/apps/headless/headless.entitlements index a4edcd5..14dde80 100644 --- a/apps/headless/headless.entitlements +++ b/apps/headless/headless.entitlements @@ -2,9 +2,8 @@ - + com.apple.developer.web-browser.public-key-credential diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 35859a7..821b530 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,6 +98,10 @@ Honest snapshot, so newcomers know what is real: E2E scripts; 11 recorded QA evidence videos in `docs/qa/evidence/`. - Tag-triggered release CI producing a macOS app zip and Linux amd64/arm64 tarballs (v1.0.0–v1.0.2 published). +- Phase 3 distribution automation for universal Developer ID macOS builds, + notarization/stapling, a checksum-pinned Homebrew cask, the verified Linux + bootstrap, release checksums, and a multi-platform GHCR image. These paths + become user-visible with the next tag. - A Next.js marketing/docs site (`apps/web`) — built, not deployed. - An agent skill (`.agents/skills/headless-computer-use/`) with safety rules, command reference, and a Docker sandbox wrapper. @@ -106,9 +110,9 @@ Honest snapshot, so newcomers know what is real: - ~~No CI on pull requests or `main`~~ — PR CI landed (`.github/workflows/ci.yml`, backlog §D1/§D3). Correctness fixes in §A are still outstanding, and the macOS E2E is nightly/label-gated rather than a per-PR gate. -- No signing/notarization, no Homebrew/apt/npm, no published Docker image, no - checksums on release assets. "Install" means "download from GitHub - Releases and click through a Gatekeeper warning". +- The npm wrapper and distro-native Linux packages remain unimplemented. + Homebrew, notarized ZIP, Linux bootstrap, checksums, and GHCR automation are + implemented but are not user-visible until the next tag. - The latest features (capture formats, context pruning) are **unreleased** — no tag since v1.0.2 (2026-07-19). - No `CLAUDE.md`/`AGENTS.md`; the skill is not auto-discovered by Claude Code. @@ -149,9 +153,9 @@ codebase's existing behavior because they are the product: | Platform | Status today | Target | | --- | --- | --- | -| **macOS 13+** (Apple Silicon) | Works; ad-hoc signed | Signed + notarized, Homebrew, universal binary | -| **macOS Intel** | Not built | Universal binary in release CI | -| **Linux** (Debian/Ubuntu, non-Snap Chromium) | Works; tarball + Docker | curl installer, published Docker image (GHCR), apt guidance | +| **macOS 13+** (Apple Silicon) | Universal signed release pipeline awaiting tag | Signed + notarized, Homebrew, universal binary | +| **macOS Intel** | Universal release pipeline awaiting tag | Universal binary in release CI | +| **Linux** (Debian/Ubuntu, non-Snap Chromium) | Verified installer and GHCR pipeline awaiting tag | curl installer, published Docker image (GHCR), apt guidance | | **Linux other distros** | Works where a non-Snap Chromium exists | Documented candidate paths per distro family | | **Windows 10/11** | Not supported | **Stretch goal (Phase W)** — Chromium host ported; not required for "done". See [architecture decisions §6](roadmap/architecture-decisions.md). | | **Any OS via Docker** | Works (build locally) | `docker run ghcr.io/…/headless` one-liner, including as the practical Windows answer (WSL2/Docker Desktop) until Phase W lands | diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index a8a9fa8..5aa8bec 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -381,6 +381,33 @@ configuration fails there with `UNSUPPORTED_CAPABILITY`. macOS E2E coverage asserts the real frontmost process for configured background and foreground startup, session creation, running-host no-op behavior, and launch overrides. +## 20. Developer ID releases omit unprovisioned passkey entitlement + +**Decision:** direct macOS releases are universal Developer ID Application +builds with hardened runtime, secure timestamps, notarization, and stapling. +They omit `com.apple.developer.web-browser.public-key-credential` by default. +The build accepts that restricted entitlement only when an Apple-approved +provisioning profile and entitlement file are both supplied explicitly for +`com.headless.app`. + +**Status:** implemented 2026-08-12 for Phase 3 distribution. + +**Rationale:** Apple's Developer ID capability set does not generally include +the restricted web-browser passkey entitlement. Claiming it without matching +provisioning approval can make macOS terminate the app and can fail +notarization. Shipping a signed app that launches reliably is stronger than +advertising a passkey path the distribution identity cannot support. The host +already detects its own entitlement and hides WebAuthn when absent so sites can +offer password, phone, or other fallback sign-in. + +**Consequences:** normal Homebrew and ZIP installs do not expose WKWebView +passkeys. Apple approval can enable them later without changing the protocol: +the release operator supplies both provisioning inputs and the existing runtime +check detects the granted entitlement. Tagged builds fail rather than falling +back to ad-hoc signing or skipping notarization. Pull-request dry runs still +exercise the universal package path with an ad-hoc signature and no Apple +credentials. + --- ## Decision log @@ -399,5 +426,6 @@ startup, session creation, running-host no-op behavior, and launch overrides. | 17 | Keep full MCP surface; annotate its maximum risk | Decided | 2026-08-10 | | 18 | Treat WebKit page diagnostics as bounded untrusted evidence | Decided | 2026-08-10 | | 19 | Keep macOS agent startup behind the current app | Implemented | 2026-08-12 | +| 20 | Omit passkeys unless Apple provisions Developer ID release | Implemented | 2026-08-12 | New decisions append here with the same format. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index d6428a1..9686050 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -359,10 +359,14 @@ and the refreshed five-repeat snapshot includes task-aware action inspection. Owner-decided scope: package managers, no hosted service. -- **E1.** ([#39](https://github.com/LockInTime/headless/issues/39)) macOS Developer ID signing + notarization + stapling (today ad-hoc, - `build.sh:96-114`); universal binary (today `uname -m` on an arm64 runner); - Homebrew tap/cask. Resolve the `com.headless.app` provisioning question for - the passkey entitlement (`.hermes` plan flagged it; `build.sh:97-109`). +- **E1.** [x] ([#39](https://github.com/LockInTime/headless/issues/39)) ~~macOS Developer ID signing + notarization + stapling; universal binary; + Homebrew tap/cask; resolve passkey provisioning.~~ **Done:** tag builds fail + closed unless every arm64/x86_64 executable is hardened-runtime Developer ID + signed and securely timestamped, accepted by `notarytool`, stapled, and + Gatekeeper-valid. A checksum-pinned cask is published by the tap's own + repository-scoped workflow. The restricted passkey entitlement is omitted unless Apple + approves a matching `com.headless.app` provisioning profile; the existing + fallback remains active otherwise. - **E2.** [x] ([#40](https://github.com/LockInTime/headless/issues/40)) ~~Linux `curl | sh` installer wrapping the existing tarball + preflight; align its FFmpeg policy with the runtime allow-list; single-source Snap detection.~~ **Done:** the release bootstrap resolves amd64/arm64, From 2974d7d6510402d3d8327beb347e67acc40909a8 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Thu, 13 Aug 2026 00:16:10 +0530 Subject: [PATCH 2/3] fix(ci): support macOS runner shell --- .github/workflows/release.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b50112f..0f87224 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,10 +155,13 @@ jobs: VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail - options=() - if [[ "$PUBLISH" == true ]]; then options+=(--notarize); fi - ./.github/scripts/macos-package.sh \ - apps/headless/Headless.app "$VERSION" apps/headless "${options[@]}" + if [[ "$PUBLISH" == true ]]; then + ./.github/scripts/macos-package.sh \ + apps/headless/Headless.app "$VERSION" apps/headless --notarize + else + ./.github/scripts/macos-package.sh \ + apps/headless/Headless.app "$VERSION" apps/headless + fi - name: Clean signing material if: always() && needs.version.outputs.publish == 'true' shell: bash From 7c983da42e13a605a74cf0514bbed824a8d24b8f Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:46:23 +0000 Subject: [PATCH 3/3] ci: rerun complete distribution validation