Skip to content
Open
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

## [Unreleased]

### Security

- Direct macOS release binaries are now signed with hardened-runtime Bun
entitlements and notarized for both architectures. Publication fails closed
unless the final tarballs pass signature, signer, Team ID, entitlement,
architecture, and Gatekeeper validation, the release Mac completes a
host-native signed OpenTUI smoke, and cross-compiled OpenTUI native packages
match their bun.lock packages-array integrity before unpack.

## [0.3.9] - 2026-08-29

### Fixed
Expand Down
63 changes: 63 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Releasing Corbits Code

Releases are operator-run from macOS with `scripts/release.sh`. The script builds
all four standalone targets and refuses to tag, publish a GitHub release, or
update the Homebrew tap unless both macOS binaries are freshly built, signed,
host-native OpenTUI-smoked on the release Mac, notarized, and validated from their
final tarballs. Cross-compiled opposite-arch macOS binaries still require signature,
notarization, and final-tarball verification, but they are never counted as
host-native smoke. Cross-compile OpenTUI native package fetches must match the
bun.lock packages-array integrity hash before unpack; nested optionalDependencies
pins are ignored for that check.

## Apple provisioning

Provision the release Mac outside this repository:

1. Install the Apple Developer ID Application certificate and private key in the
login Keychain. Record the certificate's full common name and the 10-character
Apple Team ID.
2. Store App Store Connect credentials in a named Keychain profile. Run
`xcrun notarytool store-credentials <profile>` and enter credentials only at
the interactive prompts. Never put an Apple password, app-specific password,
private key, or API key in this repository or on a release command line.
3. Set only the non-secret identifiers in the release shell:

```sh
export MACOS_SIGNING_IDENTITY='Developer ID Application: Organization Name (TEAMID1234)'
export MACOS_TEAM_ID='TEAMID1234'
export MACOS_NOTARY_PROFILE='corbits-release'
```

The identity must be the complete `Developer ID Application` certificate name.
The profile is a Keychain profile name, not a password or key. The release gate
checks the signed artifact's authority and Team ID against these values.

## Credentialed rehearsal

Before the first public release from a newly provisioned Mac, an authorized
operator must perform a credentialed, no-publication rehearsal from a clean,
disposable release branch with valid release notes:

```sh
scripts/release.sh X.Y.Z --no-push --skip-tap
```

`--no-push` suppresses remote PR, tag, and GitHub release operations; it does not
skip builds, signing, the post-sign host-native OpenTUI smoke, notarization,
tarball extraction, signature checks, entitlement comparison, architecture checks,
or Gatekeeper assessment. Opposite-arch macOS binaries still pass signature and
notarization gates; only the host architecture may satisfy the native-smoke gate.
The script creates a local version commit and tag, so use a disposable branch and
remove it through the normal Git workflow after recording the result. Do not
claim release readiness until this external rehearsal succeeds with the real
Keychain identity and Apple notary service.

## macOS distribution limitation

The published artifact is a standalone Mach-O inside a tarball, not an app or
installer bundle, so the notarization ticket cannot be stapled to it. Gatekeeper
uses Apple's online ticket lookup for the first assessment. A first launch may
therefore require internet access and can fail while Apple services are
unreachable; after macOS caches the accepted ticket, later launches can proceed
offline. This online lookup is the current macOS distribution contract.
54 changes: 54 additions & 0 deletions scripts/fetch-opentui-native.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
#
# Download one @opentui/core-* native package and unpack it only after the
# tarball matches the sha512 integrity recorded in bun.lock.
#
# scripts/fetch-opentui-native.sh PACKAGE VERSION DEST_DIR [LOCKFILE]
#
# PACKAGE is the short name after @opentui/, e.g. core-darwin-arm64.

set -euo pipefail

fail() {
printf 'OpenTUI native fetch failed: %s\n' "$1" >&2
exit 1
}

[ "$#" -eq 3 ] || [ "$#" -eq 4 ] || fail "usage: $0 PACKAGE VERSION DEST_DIR [LOCKFILE]"
pkg=$1
version=$2
dest=$3
lockfile=${4:-bun.lock}

[[ "$pkg" =~ ^core-[A-Za-z0-9_-]+$ ]] || fail "unsupported OpenTUI package name: $pkg"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]] || fail "invalid package version: $version"
[ -f "$lockfile" ] || fail "lockfile not found: $lockfile"

for tool in curl openssl tar awk mkdir rm; do
command -v "$tool" >/dev/null 2>&1 || fail "missing tool: $tool"
done

# Match the packages-array entry ("@opentui/pkg": [ ... "sha512-..." ]), not a
# nested optionalDependencies version pin that shares the same package name on
# @opentui/core's line and would otherwise yield core's integrity hash.
integrity=$(awk -v key="\"@opentui/${pkg}\": [" '
index($0, key) && match($0, /"sha512-[^"]+"/) {
print substr($0, RSTART + 1, RLENGTH - 2)
exit
}
' "$lockfile")
[ -n "$integrity" ] || fail "no bun.lock integrity for @opentui/$pkg"

temporary_directory=$(mktemp -d)
trap 'rm -rf "$temporary_directory"' EXIT
tarball="$temporary_directory/$pkg-$version.tgz"
url="https://registry.npmjs.org/@opentui/$pkg/-/$pkg-$version.tgz"

curl -fsSL "$url" -o "$tarball" || fail "could not download @opentui/$pkg@$version"
actual="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
[ "$actual" = "$integrity" ] || fail "bun.lock integrity mismatch for @opentui/$pkg (checksum)"

rm -rf "$dest"
mkdir -p "$dest"
tar -xz -C "$dest" --strip-components=1 -f "$tarball" \
|| fail "could not unpack @opentui/$pkg@$version"
12 changes: 12 additions & 0 deletions scripts/macos-entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?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>
<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>
42 changes: 42 additions & 0 deletions scripts/macos-host-native-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# Host-native OpenTUI smoke for a signed macOS release binary.
#
# scripts/macos-host-native-smoke.sh LABEL BINARY
#
# Exit codes:
# 0 — host architecture matched and the signed binary initialized OpenTUI
# 2 — LABEL is not the host architecture (caller must not count native smoke)
# 1 — host architecture matched but smoke failed
#
# Opposite-arch artifacts are never executed and never reported as smoked.

set -euo pipefail

fail() {
printf 'macOS host-native smoke failed: %s\n' "$1" >&2
exit 1
}

[ "$#" -eq 2 ] || fail "usage: $0 LABEL BINARY"
label=$1
artifact=$2

host_label() {
case "$(uname -s):$(uname -m)" in
Darwin:arm64) echo macos-arm64 ;;
Darwin:x86_64) echo macos-x64 ;;
*) echo "" ;;
esac
}

host=$(host_label)
[ -n "$host" ] || fail "host architecture is unrecognized; cannot run native smoke"
if [ "$label" != "$host" ]; then
exit 2
fi

[ -f "$artifact" ] || fail "artifact does not exist"
[ -x "$artifact" ] || fail "artifact is not executable"
"$artifact" --__release_native_smoke__ >/dev/null 2>&1 \
|| fail "signed $label binary could not initialize OpenTUI native library"
89 changes: 89 additions & 0 deletions scripts/macos-sign-and-notarize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env bash

set -euo pipefail

fail() {
printf 'macOS release validation failed: %s\n' "$1" >&2
exit 1
}

[ "$#" -eq 3 ] || fail "usage: $0 sign|notarize|verify ARTIFACT arm64|x86_64"
operation=$1
artifact=$2
expected_arch=$3

case "$operation" in
sign|notarize|verify) ;;
*) fail "unknown operation: $operation" ;;
esac
case "$expected_arch" in
arm64|x86_64) ;;
*) fail "unsupported architecture: $expected_arch" ;;
esac

[ "$(uname -s)" = Darwin ] || fail "signing and validation must run on macOS"
[ -f "$artifact" ] || fail "artifact does not exist"
[ -x "$artifact" ] || fail "artifact is not executable"

for tool in codesign ditto jq lipo plutil spctl xcrun; do
command -v "$tool" >/dev/null 2>&1 || fail "missing tool: $tool"
done
xcrun --find notarytool >/dev/null 2>&1 || fail "notarytool is unavailable"

: "${MACOS_SIGNING_IDENTITY:?MACOS_SIGNING_IDENTITY must name a Developer ID Application identity}"
: "${MACOS_TEAM_ID:?MACOS_TEAM_ID must contain the expected Apple Team ID}"
: "${MACOS_NOTARY_PROFILE:?MACOS_NOTARY_PROFILE must name a notarytool Keychain profile}"

case "$MACOS_SIGNING_IDENTITY" in
"Developer ID Application: "*) ;;
*) fail "MACOS_SIGNING_IDENTITY must name a Developer ID Application certificate" ;;
esac
[[ "$MACOS_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || fail "MACOS_TEAM_ID must be a 10-character Team ID"

script_dir=$(cd "$(dirname "$0")" && pwd)
entitlements="$script_dir/macos-entitlements.plist"
[ -f "$entitlements" ] || fail "source-controlled entitlements are missing"

temporary_directory=$(mktemp -d)
trap 'rm -rf "$temporary_directory"' EXIT

verify_artifact() {
local assess_with_gatekeeper=$1
local details actual_entitlements expected_entitlements architectures

codesign --verify --strict --verbose=2 "$artifact" >/dev/null 2>&1 || fail "strict code-signature verification failed"
details=$(codesign -dv --verbose=4 "$artifact" 2>&1) || fail "could not inspect code signature"
grep -Fqx "Authority=$MACOS_SIGNING_IDENTITY" <<< "$details" || fail "signer identity does not match"
grep -Fqx "TeamIdentifier=$MACOS_TEAM_ID" <<< "$details" || fail "Team ID does not match"

actual_entitlements="$temporary_directory/actual-entitlements.plist"
expected_entitlements="$temporary_directory/expected-entitlements.plist"
codesign -d --entitlements :- "$artifact" >"$actual_entitlements" 2>/dev/null || fail "could not read signed entitlements"
plutil -convert xml1 -o "$actual_entitlements.normalized" "$actual_entitlements" >/dev/null || fail "signed entitlements are malformed"
plutil -convert xml1 -o "$expected_entitlements" "$entitlements" >/dev/null || fail "release entitlements are malformed"
cmp -s "$actual_entitlements.normalized" "$expected_entitlements" || fail "signed entitlements do not exactly match the release entitlements"

architectures=$(lipo -archs "$artifact" 2>/dev/null) || fail "could not inspect Mach-O architecture"
[ "$architectures" = "$expected_arch" ] || fail "artifact architecture is not exactly $expected_arch"
if [ "$assess_with_gatekeeper" = 1 ]; then
spctl -a -t exec -vv "$artifact" >/dev/null 2>&1 || fail "Gatekeeper assessment failed"
fi
}

if [ "$operation" = sign ]; then
codesign --force --options runtime --timestamp --entitlements "$entitlements" \
--sign "$MACOS_SIGNING_IDENTITY" "$artifact" >/dev/null || fail "code signing failed"
verify_artifact 0
elif [ "$operation" = notarize ]; then
verify_artifact 0
archive="$temporary_directory/notarization.zip"
result="$temporary_directory/notary-result.json"
ditto -c -k --keepParent "$artifact" "$archive" || fail "could not create notarization archive"
xcrun notarytool submit "$archive" --keychain-profile "$MACOS_NOTARY_PROFILE" \
--wait --output-format json >"$result" || fail "notary submission failed"
status=$(jq -er '.status | select(type == "string")' "$result" 2>/dev/null) || fail "notarytool returned malformed JSON"
[ "$status" = Accepted ] || fail "notary status was not Accepted"
verify_artifact 1
else
verify_artifact 1
fi
Loading
Loading