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
79 changes: 79 additions & 0 deletions .github/scripts/macos-package.sh
Original file line number Diff line number Diff line change
@@ -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"
94 changes: 84 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -85,18 +120,57 @@ 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
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
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
Expand Down Expand Up @@ -269,15 +343,15 @@ 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 |
| `${{ env.CONTAINER_IMAGE }}:${{ needs.version.outputs.version }}` | Linux amd64/arm64 container |

### 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: |
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions apps/headless/Tests/macos-distribution.sh
Original file line number Diff line number Diff line change
@@ -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)"
Loading
Loading