diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fad426fb6..895585953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,6 +373,8 @@ jobs: permissions: contents: read id-token: write + env: + MACOS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_API_KEY != '' }} steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 @@ -402,6 +404,19 @@ jobs: env: RELEASE_TARGETS: ${{ matrix.target_id }} run: bun run ci:release:build-binaries + - name: Sign and notarize macOS binary (Developer ID) + # Replaces the ad-hoc signature with a Developer ID + hardened-runtime + # one (+JIT/library-validation entitlements; omp dlopens its + # runtime-extracted native addon, which has a different Team ID) and + # notarizes. Auto-skips until the APPLE_* secrets are configured. + if: matrix.platform == 'darwin' && env.MACOS_SIGNING == 'true' + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + run: bash scripts/ci-macos-sign.sh "${{ matrix.binary_path }}" # Windows binary is cross-built on Linux, so we have no Windows runner # to smoke it on. Cross-build correctness is verified via the napi # entry-point exports (see build-native action) and the bun @@ -463,6 +478,8 @@ jobs: runs-on: macos-14 permissions: contents: read + env: + MACOS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_API_KEY != '' }} steps: - name: Download published macOS arm64 binary run: | @@ -470,9 +487,22 @@ jobs: chmod +x omp-darwin-arm64 - name: Verify published macOS arm64 binary run: | - codesign -dv ./omp-darwin-arm64 + codesign -dvvv ./omp-darwin-arm64 + codesign --verify --strict --verbose=4 ./omp-darwin-arm64 runtime_dir="$(mktemp -d)" HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" ./omp-darwin-arm64 --version + HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" ./omp-darwin-arm64 --smoke-test + - name: Assert signed release is not ad-hoc + if: env.MACOS_SIGNING == 'true' + run: | + if codesign -dvvv ./omp-darwin-arm64 2>&1 | grep -qE "flags=.*adhoc|Signature=adhoc"; then + echo "published binary is still ad-hoc signed (Developer ID signing did not run)" >&2 + exit 1 + fi + # Gatekeeper assessment: a notarized Developer ID binary is accepted. + # Informational — a bare (unstapled) Mach-O relies on the online ticket + # lookup, so surface the result without gating the release on it. + spctl -a -t exec -vv ./omp-darwin-arm64 || echo "spctl non-zero (expected for unstapled bare binary; ticket served online)" release-npm: if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() && @@ -513,3 +543,44 @@ jobs: # publisher for the package (or on a first publish). NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun run ci:release:publish + + # Regenerate the Homebrew tap formula (can1357/homebrew-tap) from the freshly + # published release assets and push it. Depends only on release-github (the + # release and its binaries must exist). No-ops when HOMEBREW_TAP_DEPLOY_KEY is + # unset, so a release never blocks on tap access. + release_brew: + if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() && + needs['release-github'].result == 'success' }} + needs: [gate, release-github] + runs-on: ubuntu-22.04 + env: + HAS_TAP_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY != '' }} + steps: + - uses: actions/checkout@v4 + if: env.HAS_TAP_KEY == 'true' + - uses: oven-sh/setup-bun@v2 + if: env.HAS_TAP_KEY == 'true' + with: + bun-version: "1.3" + - name: Check out the Homebrew tap + if: env.HAS_TAP_KEY == 'true' + uses: actions/checkout@v4 + with: + repository: can1357/homebrew-tap + ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + path: homebrew-tap + - name: Regenerate and push the formula + if: env.HAS_TAP_KEY == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun scripts/ci-update-brew-formula.ts "${{ needs.gate.outputs.release-tag }}" --out homebrew-tap/Formula/omp.rb + cd homebrew-tap + if git diff --quiet -- Formula/omp.rb; then + echo "formula already up to date for ${{ needs.gate.outputs.release-tag }}" + exit 0 + fi + git -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "omp ${{ needs.gate.outputs.release-tag }}" -- Formula/omp.rb + git push origin HEAD:main diff --git a/README.md b/README.md index f8c32929a..a9fbe3395 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ The most capable agent surface that ships. Continuously tuned by real-world use curl -fsSL https://omp.sh/install | sh ``` +**Homebrew** + +```sh +brew install can1357/tap/omp +``` + **Bun (recommended)** ```sh diff --git a/docs/macos-signing-notarization.md b/docs/macos-signing-notarization.md new file mode 100644 index 000000000..191a3161c --- /dev/null +++ b/docs/macos-signing-notarization.md @@ -0,0 +1,125 @@ +# macOS signing & notarization + +The compiled macOS `omp` binaries shipped on GitHub Releases are signed with a +**Developer ID Application** certificate and **notarized** by Apple. This makes +them Gatekeeper-acceptable and is the prerequisite for an official Homebrew +submission (see [#776](https://github.com/can1357/oh-my-pi/issues/776)). + +Signing happens in CI, in the `release_binary` job's darwin matrix legs +(`.github/workflows/ci.yml`), via `scripts/ci-macos-sign.sh`. It **auto-skips** +until the `APPLE_*` repository secrets below are configured, so releases keep +working (ad-hoc signed, as before) in the meantime. + +## How it works + +1. `ci:release:build-binaries` builds and **ad-hoc** signs the binary (so it can + run on the build runner). +2. `scripts/ci-macos-sign.sh` then: + - imports the Developer ID cert into a throwaway keychain; + - re-signs with `--options runtime --timestamp` (hardened runtime + secure + timestamp) and `--entitlements scripts/macos-entitlements.plist`; + - runs `--version` and `--smoke-test` under the new signature to fail fast; + - notarizes the binary via `notarytool submit --wait`. +3. `release_github_verify` re-downloads the published arm64 asset and asserts it + is **not** ad-hoc, passes `codesign --verify --strict`, and boots cleanly. + +### Why the entitlements are mandatory + +The binary is a Bun single-file executable, so the hardened runtime needs: + +| Entitlement | Reason | +| --- | --- | +| `com.apple.security.cs.allow-jit` | JavaScriptCore JITs at runtime. | +| `com.apple.security.cs.allow-unsigned-executable-memory` | JSC executable memory pages. | +| `com.apple.security.cs.disable-library-validation` | omp extracts its native addon (`pi_natives..node`) and other optional dylibs to a runtime cache and `dlopen()`s them. They do not share the main binary's Team ID, so without this the hardened runtime aborts with *"mapping process and mapped file have different Team IDs"* — breaking effectively every command. | + +Without `disable-library-validation`, a signed+notarized binary signs and +notarizes fine but **fails at first real use**. `scripts/ci-macos-sign.sh` runs +`--smoke-test` after signing specifically to catch this before notarizing. + +### Stapling limitation (important) + +A bare Mach-O executable **cannot be stapled** (`stapler` only supports +`.app`/`.pkg`/`.dmg`). The binary is genuinely notarized — `notarytool` returns +`Accepted` and the ticket exists on Apple's servers keyed to its cdhash — but +because there is no *stapled* ticket, a direct `spctl -a -t exec` assessment +reports `rejected / source=Unnotarized Developer ID`. This is expected and is +**not** a signing or credential failure. + +What this means in practice: + +- `curl https://omp.sh/install | sh` — `curl` sets no quarantine bit, so + Gatekeeper is never consulted; the binary just runs. ✅ +- Homebrew **formula** installs — Homebrew does not quarantine formula files, so + Gatekeeper is never consulted. ✅ +- Anything that **quarantines** the binary (a browser download, or a Homebrew + **cask**) and is assessed offline will be blocked, because there is no stapled + ticket. For that route, wrap the binary in a stapleable, notarized **`.pkg` or + `.dmg`** (`xcrun stapler staple` works on those). That is a follow-up and is + **not** required for the `curl`/formula paths. + +## Required GitHub secrets + +Add these under **Settings → Secrets and variables → Actions** (repo secrets). +Both the cert (`APPLE_CERTIFICATE_P12`) **and** the API key (`APPLE_API_KEY`) +must be present for signing to engage. + +| Secret | What it is | +| --- | --- | +| `APPLE_CERTIFICATE_P12` | base64 of the exported Developer ID Application `.p12` (cert + private key). | +| `APPLE_CERTIFICATE_PASSWORD` | password you set when exporting the `.p12`. | +| `APPLE_API_KEY_ID` | App Store Connect API **Key ID**. | +| `APPLE_API_ISSUER_ID` | App Store Connect API **Issuer ID** (UUID). | +| `APPLE_API_KEY` | base64 of the App Store Connect `.p8` private key. | + +### Producing the credential files + +Drop these into a working directory (default `~/omp-signing`): + +| File | How | +| --- | --- | +| `*.p12` | **Keychain Access** → right-click your *Developer ID Application: …* identity (the entry that expands to a cert **with** a private key) → **Export…** → save as `.p12` and set a password. | +| `p12-password.txt` | the password you just set on the `.p12`. | +| `AuthKey_.p8` | App Store Connect → **Users and Access → Integrations → App Store Connect API** → create a key (**Account Holder** role also allows API cert creation; **Developer** is enough for notarization) → **download once** (non-recoverable). | +| `issuer-id.txt` | the **Issuer ID** (UUID) shown above the keys table. | +| `key-id.txt` | *optional* — the Key ID; otherwise read from the `.p8` filename. | + +The App Store Connect API key is the one credential that **cannot** be minted +from a CLI — it is the bootstrap credential for the API itself, and the `.p8` +downloads exactly once. Everything else is local. + +### Uploading (no value leaves disk) + +`scripts/ci-macos-upload-secrets.sh` validates the files (opens the `.p12` with +your password, sanity-checks the `.p8`) and pipes each value to `gh secret set` +over stdin — no secret is ever printed to the terminal, argv, or shell history: + +```sh +scripts/ci-macos-upload-secrets.sh ~/omp-signing --dry-run # validate first +scripts/ci-macos-upload-secrets.sh ~/omp-signing # upload all five +gh secret list --repo can1357/oh-my-pi # confirm +``` + +Re-run it whenever the certificate is renewed. + +### Finding your signing identity / Team ID (sanity check) + +```sh +security find-identity -v -p codesigning +# e.g. "Developer ID Application: Your Name (TEAMID1234)" +``` + +The script selects the first `Developer ID Application` identity automatically; +you do not need to store the identity string or Team ID as a secret. + +## Local dry run + +You can exercise the full sign+notarize path locally (real cert + API key) by +exporting the five env vars and running: + +```sh +RELEASE_TARGETS=darwin-arm64 bun run ci:release:build-binaries +APPLE_CERTIFICATE_P12=… APPLE_CERTIFICATE_PASSWORD=… \ +APPLE_API_KEY_ID=… APPLE_API_ISSUER_ID=… APPLE_API_KEY=… \ + bash scripts/ci-macos-sign.sh packages/coding-agent/binaries/omp-darwin-arm64 +``` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 267f40a35..11996a862 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- macOS release binaries are now signed with a Developer ID Application identity (hardened runtime + secure timestamp + JIT/library-validation entitlements) and notarized in CI when the `APPLE_*` signing secrets are configured; releases auto-fall back to ad-hoc signing until then. This makes the shipped binaries Gatekeeper-acceptable, unblocking an official Homebrew submission ([#776](https://github.com/can1357/oh-my-pi/issues/776)). See `docs/macos-signing-notarization.md`. +- Added a Homebrew install path: `brew install can1357/tap/omp`. The [can1357/homebrew-tap](https://github.com/can1357/homebrew-tap) formula installs the prebuilt release binary, and a `release_brew` CI job regenerates it (version + per-asset sha256) from each published release via `scripts/ci-update-brew-formula.ts` ([#776](https://github.com/can1357/oh-my-pi/issues/776)). + +### Changed + +- Rewrote the session auto-title prompt (`prompts/system/title-system.md`) and the `set_title` tool description to ask for a concise, sentence-case title (3-7 words) that captures the session's topic/goal, with good/bad examples and explicit guidance to treat the first message as data (no following embedded links/instructions, no refusals, describe URL/reference asks). The local on-device title prompt (`tiny-title-system.md`) was aligned to the same 3-7 word, sentence-case convention. The deterministic greeting/low-signal filter and the `none` deferral sentinel are unchanged. + ## [15.10.3] - 2026-06-08 ### Added diff --git a/scripts/ci-macos-sign.sh b/scripts/ci-macos-sign.sh new file mode 100755 index 000000000..824747d06 --- /dev/null +++ b/scripts/ci-macos-sign.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# Sign and notarize a compiled macOS `omp` binary with a Developer ID identity. +# +# The release build (`ci:release:build-binaries`) ad-hoc signs the binary so it +# runs locally. This script *replaces* that signature with a real Developer ID +# Application signature plus the hardened runtime, a secure timestamp, and the +# JIT / library-validation entitlements the Bun + JavaScriptCore runtime and the +# runtime-extracted native addon require (see scripts/macos-entitlements.plist), +# then notarizes the result with App Store Connect API credentials. +# +# A bare Mach-O executable cannot be stapled (stapler only supports .app/.pkg/ +# .dmg), so the notarization ticket is served online: Gatekeeper fetches it by +# cdhash on first assessment. `curl` downloads and Homebrew *formula* installs do +# not set the quarantine bit, so they never invoke Gatekeeper; for an offline, +# quarantined cask we would need a stapleable .pkg/.dmg wrapper (follow-up). +# +# Required environment (wired from GitHub Actions secrets): +# APPLE_CERTIFICATE_P12 base64 of the Developer ID Application .p12 bundle +# APPLE_CERTIFICATE_PASSWORD password protecting that .p12 +# APPLE_API_KEY_ID App Store Connect API key id (the "Key ID") +# APPLE_API_ISSUER_ID App Store Connect API issuer id (UUID) +# APPLE_API_KEY base64 of the App Store Connect .p8 private key +# +# Usage: scripts/ci-macos-sign.sh + +set -euo pipefail + +if [[ "${OSTYPE:-}" != darwin* ]]; then + echo "ci-macos-sign: must run on macOS" >&2 + exit 1 +fi + +BINARY="${1:-}" +if [[ -z "$BINARY" ]]; then + echo "usage: ci-macos-sign.sh " >&2 + exit 1 +fi +if [[ ! -f "$BINARY" ]]; then + echo "ci-macos-sign: binary not found: $BINARY" >&2 + exit 1 +fi + +missing=() +for var in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY; do + [[ -n "${!var:-}" ]] || missing+=("$var") +done +if ((${#missing[@]})); then + echo "ci-macos-sign: missing required env: ${missing[*]}" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTITLEMENTS="$SCRIPT_DIR/macos-entitlements.plist" +if [[ ! -f "$ENTITLEMENTS" ]]; then + echo "ci-macos-sign: entitlements not found: $ENTITLEMENTS" >&2 + exit 1 +fi + +WORKDIR="$(mktemp -d)" +KEYCHAIN="$WORKDIR/omp-signing.keychain-db" +KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" +CERT_PATH="$WORKDIR/cert.p12" +API_KEY_PATH="$WORKDIR/api-key.p8" +ZIP_PATH="$WORKDIR/$(basename "$BINARY").zip" + +cleanup() { + security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +echo "ci-macos-sign: decoding credentials" +printf '%s' "$APPLE_CERTIFICATE_P12" | base64 --decode >"$CERT_PATH" +printf '%s' "$APPLE_API_KEY" | base64 --decode >"$API_KEY_PATH" + +echo "ci-macos-sign: provisioning a temporary signing keychain" +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +# Auto-relock after 6h as a safety net; the EXIT trap deletes it well before. +security set-keychain-settings -lut 21600 "$KEYCHAIN" +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +# Prepend our keychain to the user search list so codesign can resolve the +# identity, keeping the runner's existing keychains intact. +existing_keychains="$(security list-keychains -d user | sed -e 's/"//g' -e 's/^[[:space:]]*//')" +# shellcheck disable=SC2086 # intentional word-splitting of the keychain list +security list-keychains -d user -s "$KEYCHAIN" $existing_keychains + +security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -k "$KEYCHAIN" \ + -T /usr/bin/codesign -T /usr/bin/security +# Grant codesign non-interactive access to the imported private key. +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + +IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}')" +if [[ -z "$IDENTITY" ]]; then + echo "ci-macos-sign: no 'Developer ID Application' identity in the imported keychain" >&2 + security find-identity -v -p codesigning "$KEYCHAIN" >&2 || true + exit 1 +fi +echo "ci-macos-sign: signing as: $IDENTITY" + +codesign --force --timestamp --options runtime \ + --entitlements "$ENTITLEMENTS" \ + --sign "$IDENTITY" \ + "$BINARY" + +echo "ci-macos-sign: verifying signature" +codesign --verify --strict --verbose=4 "$BINARY" +codesign -dvvv "$BINARY" 2>&1 | grep -E "Authority|TeamIdentifier|flags=|Timestamp" || true + +# Fail fast before the slower notarization round-trip: a hardened-runtime binary +# missing an entitlement still signs cleanly but aborts at launch (e.g. the +# native-addon Team ID check). Exercise the runtime in an isolated HOME. +echo "ci-macos-sign: launch check under the hardened-runtime signature" +run_home="$WORKDIR/home" +HOME="$run_home" XDG_DATA_HOME="$run_home/xdg" "$BINARY" --version +HOME="$run_home" XDG_DATA_HOME="$run_home/xdg" "$BINARY" --smoke-test + +echo "ci-macos-sign: submitting for notarization" +/usr/bin/ditto -c -k --keepParent "$BINARY" "$ZIP_PATH" +submit_json="$(xcrun notarytool submit "$ZIP_PATH" \ + --key "$API_KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --wait \ + --timeout 30m \ + --output-format json)" +echo "$submit_json" + +read -r status submission_id <<<"$(printf '%s' "$submit_json" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("status",""), d.get("id",""))')" + +if [[ "$status" != "Accepted" ]]; then + echo "ci-macos-sign: notarization status=$status (expected Accepted)" >&2 + if [[ -n "$submission_id" ]]; then + xcrun notarytool log "$submission_id" \ + --key "$API_KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" >&2 || true + fi + exit 1 +fi + +echo "ci-macos-sign: notarized ($(basename "$BINARY"), submission $submission_id)" +echo "ci-macos-sign: note — a bare Mach-O cannot be stapled; the ticket is verified online." diff --git a/scripts/ci-macos-upload-secrets.sh b/scripts/ci-macos-upload-secrets.sh new file mode 100755 index 000000000..c7282b2e3 --- /dev/null +++ b/scripts/ci-macos-upload-secrets.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# Upload the macOS signing/notarization secrets to GitHub Actions WITHOUT ever +# printing a secret value. Every value is read from a file on disk and piped to +# `gh secret set` over stdin, so nothing lands in argv, the shell history, or a +# terminal transcript. +# +# Prepare a directory (default ~/omp-signing) containing: +# *.p12 Developer ID Application identity exported from Keychain +# Access (right-click identity -> Export -> .p12). +# p12-password.txt the password you set on that .p12 export. +# AuthKey_.p8 App Store Connect API key (download-once from the web). +# issuer-id.txt App Store Connect API issuer id (UUID). +# key-id.txt optional; otherwise the is read from the .p8 +# filename. +# +# Usage: +# scripts/ci-macos-upload-secrets.sh [dir] [--dry-run] +# OMP_REPO=owner/repo scripts/ci-macos-upload-secrets.sh ~/omp-signing + +set -euo pipefail + +DIR="" +DRY_RUN=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + *) DIR="$arg" ;; + esac +done +DIR="${DIR:-${OMP_SIGNING_DIR:-$HOME/omp-signing}}" +REPO="${OMP_REPO:-can1357/oh-my-pi}" + +die() { + echo "ci-macos-upload-secrets: $1" >&2 + exit 1 +} + +[[ -d "$DIR" ]] || die "directory not found: $DIR" + +find_one() { + # Echo the single file in $DIR matching the glob, or fail. + local pattern="$1" matches=() + while IFS= read -r f; do matches+=("$f"); done < <(find "$DIR" -maxdepth 1 -type f -name "$pattern" | sort) + ((${#matches[@]} == 1)) || die "expected exactly one '$pattern' in $DIR, found ${#matches[@]}" + printf '%s' "${matches[0]}" +} + +read_file_value() { + # Trim a single trailing newline; reject empty. + local path="$1" name="$2" value + [[ -f "$path" ]] || die "missing $name file: $path" + value="$(cat "$path")" + [[ -n "$value" ]] || die "$name file is empty: $path" + printf '%s' "$value" +} + +P12="$(find_one '*.p12')" +P8="$(find_one '*.p8')" +PW="$(read_file_value "$DIR/p12-password.txt" "p12-password.txt")" +ISSUER="$(read_file_value "$DIR/issuer-id.txt" "issuer-id.txt")" + +if [[ -f "$DIR/key-id.txt" ]]; then + KEYID="$(read_file_value "$DIR/key-id.txt" "key-id.txt")" +else + # AuthKey_ABCDE12345.p8 -> ABCDE12345 + KEYID="$(basename "$P8" .p8)" + KEYID="${KEYID#AuthKey_}" + [[ -n "$KEYID" && "$KEYID" != "$(basename "$P8" .p8)" ]] \ + || die "could not derive key id from '$(basename "$P8")'; add key-id.txt" +fi + +# Validate the .p12 + password the same way CI consumes it — `security import` +# into a throwaway keychain — and confirm a Developer ID identity is inside, so a +# typo or wrong cert fails here instead of in CI. (We avoid `openssl pkcs12`: +# OpenSSL 3.x can't read the legacy RC2-40-CBC algorithm Keychain Access still +# uses, which `security import` handles fine.) +validate_p12=$( + kc="$(mktemp -d)/validate.keychain-db" + kp="$(openssl rand -hex 16)" + security create-keychain -p "$kp" "$kc" >/dev/null 2>&1 + security unlock-keychain -p "$kp" "$kc" >/dev/null 2>&1 + if security import "$P12" -P "$PW" -k "$kc" -T /usr/bin/codesign >/dev/null 2>&1 \ + && security find-identity -v -p codesigning "$kc" 2>/dev/null | grep -q "Developer ID Application"; then + echo ok + fi + security delete-keychain "$kc" >/dev/null 2>&1 || true +) +[[ "$validate_p12" == ok ]] \ + || die "the .p12 did not import with the password in p12-password.txt, or holds no Developer ID Application identity" +grep -q "BEGIN PRIVATE KEY" "$P8" \ + || die "the .p8 does not look like a PEM private key" + +echo "ci-macos-upload-secrets: repo=$REPO" +echo " cert : $(basename "$P12")" +echo " key : $(basename "$P8") (key id $KEYID)" +echo " -> APPLE_CERTIFICATE_P12, APPLE_CERTIFICATE_PASSWORD, APPLE_API_KEY_ID, APPLE_API_ISSUER_ID, APPLE_API_KEY" + +if ((DRY_RUN)); then + echo "ci-macos-upload-secrets: --dry-run, not uploading" + exit 0 +fi + +set_secret_stdin() { + # $1 = secret name; value piped on stdin. Never echoes the value. + gh secret set "$1" --repo "$REPO" +} + +base64 <"$P12" | tr -d '\n' | set_secret_stdin APPLE_CERTIFICATE_P12 +printf '%s' "$PW" | set_secret_stdin APPLE_CERTIFICATE_PASSWORD +printf '%s' "$KEYID" | set_secret_stdin APPLE_API_KEY_ID +printf '%s' "$ISSUER" | set_secret_stdin APPLE_API_ISSUER_ID +base64 <"$P8" | tr -d '\n' | set_secret_stdin APPLE_API_KEY + +echo "ci-macos-upload-secrets: done. Verify with: gh secret list --repo $REPO" diff --git a/scripts/ci-update-brew-formula.ts b/scripts/ci-update-brew-formula.ts new file mode 100755 index 000000000..36a2efad5 --- /dev/null +++ b/scripts/ci-update-brew-formula.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env bun +// +// Render the Homebrew formula for `omp` from a published GitHub release and write +// it to a tap checkout. The release publishes per-platform bare binaries +// (omp--); this reads their sha256 digests straight from the +// release metadata so the formula never drifts from the shipped assets. +// +// Usage: +// bun scripts/ci-update-brew-formula.ts --out +// bun scripts/ci-update-brew-formula.ts v15.10.3 # prints to stdout + +import { $ } from "bun"; + +const REPO = process.env.OMP_REPO ?? "can1357/oh-my-pi"; +const HOMEPAGE = "https://omp.sh"; +const DESC = "Coding agent with the IDE wired in"; + +interface ReleaseAsset { + name: string; + digest?: string; +} + +function parseArgs(argv: readonly string[]): { tag: string; out: string | null } { + const rest = [...argv]; + let out: string | null = null; + const outIdx = rest.findIndex(a => a === "--out"); + if (outIdx >= 0) { + out = rest[outIdx + 1] ?? null; + if (!out) throw new Error("--out requires a path"); + rest.splice(outIdx, 2); + } + const tag = rest.find(a => !a.startsWith("--")); + if (!tag) throw new Error("usage: ci-update-brew-formula.ts [--out ]"); + return { tag, out }; +} + +async function fetchAssets(tag: string): Promise { + const res = await $`gh release view ${tag} --repo ${REPO} --json assets`.quiet().nothrow(); + if (res.exitCode !== 0) { + throw new Error(`gh release view ${tag} failed: ${res.stderr.toString().trim()}`); + } + const parsed = JSON.parse(res.stdout.toString()) as { assets: ReleaseAsset[] }; + return parsed.assets; +} + +function sha256For(assets: readonly ReleaseAsset[], name: string): string { + const asset = assets.find(a => a.name === name); + if (!asset) throw new Error(`release is missing asset ${name}`); + if (!asset.digest?.startsWith("sha256:")) { + throw new Error(`asset ${name} has no sha256 digest (got ${asset.digest ?? "none"})`); + } + return asset.digest.slice("sha256:".length); +} + +// `${...}` is JS interpolation; the literal `#{version}` / `#{bin}` below are +// Ruby interpolations Homebrew resolves when it evaluates the formula. +function renderFormula(version: string, sums: Record): string { + return `class Omp < Formula + desc "${DESC}" + homepage "${HOMEPAGE}" + version "${version}" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/${REPO}/releases/download/v#{version}/omp-darwin-arm64" + sha256 "${sums["omp-darwin-arm64"]}" + end + on_intel do + url "https://github.com/${REPO}/releases/download/v#{version}/omp-darwin-x64" + sha256 "${sums["omp-darwin-x64"]}" + end + end + + on_linux do + on_arm do + url "https://github.com/${REPO}/releases/download/v#{version}/omp-linux-arm64" + sha256 "${sums["omp-linux-arm64"]}" + end + on_intel do + url "https://github.com/${REPO}/releases/download/v#{version}/omp-linux-x64" + sha256 "${sums["omp-linux-x64"]}" + end + end + + def install + bin.install Dir["omp-*"].first => "omp" + (bin/"omp").chmod 0555 + generate_completions_from_executable(bin/"omp", "completions", shells: [:bash, :zsh, :fish]) + end + + test do + assert_match version.to_s, shell_output("#{bin}/omp --version") + end +end +`; +} + +async function main(): Promise { + const { tag, out } = parseArgs(process.argv.slice(2)); + const version = tag.replace(/^v/, ""); + const assets = await fetchAssets(tag); + + const targets = ["omp-darwin-arm64", "omp-darwin-x64", "omp-linux-arm64", "omp-linux-x64"]; + const sums: Record = {}; + for (const name of targets) sums[name] = sha256For(assets, name); + + const formula = renderFormula(version, sums); + if (out) { + await Bun.write(out, formula); + console.log(`wrote ${out} for ${tag}`); + } else { + process.stdout.write(formula); + } +} + +await main(); diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist new file mode 100644 index 000000000..76f49fa23 --- /dev/null +++ b/scripts/macos-entitlements.plist @@ -0,0 +1,26 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + +