ci: added macOS release signing and Homebrew automation to CI
- Added macOS CI signing and notarization steps when APPLE_* secrets are configured. - Added strict darwin verification checks to reject ad-hoc signatures and run smoke tests. - Added Homebrew formula publishing from release assets with SHA-256 checksums. - Added helper scripts for signing secret upload, entitlements, and release workflows.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<triple>.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_<KEYID>.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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Executable
+145
@@ -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 <path-to-binary>
|
||||
|
||||
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 <path-to-binary>" >&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."
|
||||
Executable
+115
@@ -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_<KEYID>.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 <KEYID> 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"
|
||||
Executable
+117
@@ -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-<platform>-<arch>); 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 <tag> --out <path/to/Formula/omp.rb>
|
||||
// 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 <tag> [--out <file>]");
|
||||
return { tag, out };
|
||||
}
|
||||
|
||||
async function fetchAssets(tag: string): Promise<ReleaseAsset[]> {
|
||||
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, string>): 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<void> {
|
||||
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<string, string> = {};
|
||||
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();
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
Entitlements for the hardened-runtime Developer ID signature applied to the
|
||||
compiled `omp` macOS binary (see scripts/ci-macos-sign.sh).
|
||||
|
||||
These are NOT optional. The binary is a Bun single-file executable, and:
|
||||
* allow-jit / allow-unsigned-executable-memory — JavaScriptCore JITs at
|
||||
runtime; the hardened runtime kills JIT (MAP_JIT) pages without these.
|
||||
* disable-library-validation — omp extracts its native addon
|
||||
(pi_natives.<triple>.node) and other optional dylibs to a runtime cache
|
||||
and dlopen()s them. Those dylibs do not share the main binary's Team ID,
|
||||
so without this entitlement the hardened runtime refuses to map them
|
||||
("mapping process and mapped file have different Team IDs") and every
|
||||
command that touches natives (i.e. effectively all of them) aborts.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user