From ef83a143b8de596d3f96a0c389a4b90bfc815e9d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 23:37:13 -0700 Subject: [PATCH 1/5] Harden macOS release signing and notarization --- CHANGELOG.md | 7 ++ docs/RELEASING.md | 56 ++++++++++ scripts/macos-entitlements.plist | 10 ++ scripts/macos-sign-and-notarize.sh | 88 +++++++++++++++ scripts/release.sh | 30 +++++- tests/unit/macos-release-signing.test.ts | 131 +++++++++++++++++++++++ 6 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 docs/RELEASING.md create mode 100644 scripts/macos-entitlements.plist create mode 100755 scripts/macos-sign-and-notarize.sh create mode 100644 tests/unit/macos-release-signing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c39979e..f3285791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ 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. + ## [0.3.9] - 2026-08-29 ### Fixed diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..b2452f48 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,56 @@ +# 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, +notarized, and validated from their final tarballs. + +## 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 ` 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, notarization, tarball extraction, signature checks, +entitlement comparison, architecture checks, or Gatekeeper assessment. 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. diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist new file mode 100644 index 00000000..26b12f28 --- /dev/null +++ b/scripts/macos-entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/scripts/macos-sign-and-notarize.sh b/scripts/macos-sign-and-notarize.sh new file mode 100755 index 00000000..55c18eff --- /dev/null +++ b/scripts/macos-sign-and-notarize.sh @@ -0,0 +1,88 @@ +#!/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-and-notarize|verify ARTIFACT arm64|x86_64" +operation=$1 +artifact=$2 +expected_arch=$3 + +case "$operation" in + sign-and-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-and-notarize ]; then + codesign --force --options runtime --timestamp --entitlements "$entitlements" \ + --sign "$MACOS_SIGNING_IDENTITY" "$artifact" >/dev/null || fail "code signing failed" + 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 diff --git a/scripts/release.sh b/scripts/release.sh index 5a5a8e4a..0e1e168b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -35,7 +35,7 @@ # failed release reads as success. # # Requirements: run on a Mac with git, gh (authenticated), bun, jq, ar, tar, -# and shasum available. `bun build --compile` cross-compiles every target +# shasum, and the Apple signing tools available. `bun build --compile` cross-compiles every target # from here; no Linux host is needed. For the tap step, the corbitsdev/tap # tap must be tapped (brew tap corbitsdev/tap) or reachable so it can clone. @@ -88,6 +88,7 @@ RELEASE_BRANCH="release-$VERSION" ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) cd "$ROOT" STAGE="$ROOT/dist/release" +MACOS_RELEASE_HELPER="$ROOT/scripts/macos-sign-and-notarize.sh" MAINTAINER="$(git config user.name) <$(git config user.email)>" step() { printf '\n\033[1;34m==>\033[0m \033[1m%s\033[0m\n' "$*"; } @@ -245,7 +246,13 @@ fetch_native_modules() { # ---- preflight ------------------------------------------------------------- step "Preflight for $TAG" -for t in git gh bun jq ar tar shasum; do command -v "$t" >/dev/null || die "missing tool: $t"; done +[ "$(uname -s)" = Darwin ] || die "releases must run on macOS" +for t in git gh bun jq ar tar shasum codesign ditto lipo plutil spctl xcrun; do command -v "$t" >/dev/null || die "missing tool: $t"; done +xcrun --find notarytool >/dev/null 2>&1 || die "missing tool: notarytool" +[ -x "$MACOS_RELEASE_HELPER" ] || die "macOS signing helper is missing or not executable" +: "${MACOS_SIGNING_IDENTITY:?set MACOS_SIGNING_IDENTITY to the Developer ID Application certificate name}" +: "${MACOS_TEAM_ID:?set MACOS_TEAM_ID to the expected Apple Team ID}" +: "${MACOS_NOTARY_PROFILE:?set MACOS_NOTARY_PROFILE to the notarytool Keychain profile name}" gh auth status >/dev/null 2>&1 || die "gh is not authenticated (run: gh auth login)" info "installing dependencies (bun install)" bun install >/dev/null 2>&1 || die "bun install failed" @@ -339,11 +346,12 @@ fi # ---- 3. build binaries, smoke, tarballs, and debs -------------------------- step "Build standalone binaries and packages" mkdir -p "$STAGE" +validated_macos=0 for entry in "${TARGETS[@]}"; do IFS='|' read -r label target kind debarch <<< "$entry" pkg="$FORMULA-$VERSION-$label" tarball="$STAGE/$pkg.tar.gz" - if [ -f "$tarball" ] && [ -f "$tarball.sha256" ]; then + if [ "$kind" != macos ] && [ -f "$tarball" ] && [ -f "$tarball.sha256" ]; then skip "$pkg.tar.gz already built" else info "compiling $label ($target)" @@ -358,7 +366,22 @@ for entry in "${TARGETS[@]}"; do if [ -d "$ROOT/plugins" ]; then cp -R "$ROOT/plugins" "$STAGE/$pkg/plugins" fi + if [ "$kind" = macos ]; then + case "$label" in + macos-arm64) macos_arch=arm64 ;; + macos-x64) macos_arch=x86_64 ;; + *) die "unknown macOS release architecture: $label" ;; + esac + "$MACOS_RELEASE_HELPER" sign-and-notarize "$STAGE/$pkg/$FORMULA" "$macos_arch" + fi tar -C "$STAGE" -czf "$tarball" "$pkg" + if [ "$kind" = macos ]; then + verify_dir=$(mktemp -d) + tar -xzf "$tarball" -C "$verify_dir" || die "could not extract final $label tarball" + "$MACOS_RELEASE_HELPER" verify "$verify_dir/$pkg/$FORMULA" "$macos_arch" + rm -rf "$verify_dir" + validated_macos=$((validated_macos + 1)) + fi ( cd "$STAGE" && shasum -a 256 "$pkg.tar.gz" > "$pkg.tar.gz.sha256" ) rm -rf "$STAGE/$pkg" info "packaged $pkg.tar.gz ($(cd "$STAGE" && du -h "$pkg.tar.gz" | cut -f1))" @@ -384,6 +407,7 @@ for entry in "${TARGETS[@]}"; do fi rm -f "$STAGE/$FORMULA-$label.bin" done +[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation" # ---- 4. land the release commit on main via PR, then tag ------------------ # A direct push to main is rejected by the branch ruleset ("N of N required diff --git a/tests/unit/macos-release-signing.test.ts b/tests/unit/macos-release-signing.test.ts new file mode 100644 index 00000000..ce412fb6 --- /dev/null +++ b/tests/unit/macos-release-signing.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dir, "../.."); +const helper = join(root, "scripts/macos-sign-and-notarize.sh"); +const entitlements = join(root, "scripts/macos-entitlements.plist"); +const temporaryDirectories: string[] = []; + +async function createFixture() { + const directory = await mkdtemp(join(tmpdir(), "corbits-macos-signing-")); + temporaryDirectories.push(directory); + const binDirectory = join(directory, "bin"); + const artifact = join(directory, "corbits"); + await mkdir(binDirectory); + await writeFile(artifact, "stub Mach-O"); + await chmod(artifact, 0o755); + + const command = async (name: string, body: string) => { + const path = join(binDirectory, name); + await writeFile(path, `#!/bin/sh\nset -eu\n${body}\n`); + await chmod(path, 0o755); + }; + + await command( + "codesign", + `case " $* " in + *" --entitlements :- "*) cat "$STUB_ENTITLEMENTS_FILE" ;; + *" -dv "*) + [ "\${STUB_SIGNED:-1}" = 1 ] || exit 1 + printf 'Authority=%s\\nTeamIdentifier=%s\\n' "\${STUB_AUTHORITY:-$MACOS_SIGNING_IDENTITY}" "\${STUB_TEAM:-$MACOS_TEAM_ID}" >&2 ;; + *" --verify "*) [ "\${STUB_SIGNED:-1}" = 1 ] ;; + *) : ;; +esac`, + ); + await command( + "xcrun", + `[ -z "\${STUB_NOTARY_JSON:-}" ] && STUB_NOTARY_JSON='{"status":"Accepted"}' +printf '%s\\n' "$STUB_NOTARY_JSON"`, + ); + await command("uname", `printf 'Darwin\\n'`); + await command("spctl", `[ "\${STUB_SPCTL_OK:-1}" = 1 ]`); + await command("lipo", `printf '%s\\n' "\${STUB_ARCHES:-arm64}"`); + await command("ditto", `: > "$5"`); + await command("plutil", `cp "$5" "$4"`); + + const run = (operation = "sign-and-notarize", architecture = "arm64", overrides = {}) => + Bun.spawnSync({ + cmd: ["bash", helper, operation, artifact, architecture], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + MACOS_SIGNING_IDENTITY: "Developer ID Application: Corbits Labs (TEAM123456)", + MACOS_TEAM_ID: "TEAM123456", + MACOS_NOTARY_PROFILE: "corbits-release", + STUB_ENTITLEMENTS_FILE: entitlements, + ...overrides, + }, + stdout: "pipe", + stderr: "pipe", + }); + + return { run }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("macOS release signing gate", () => { + test("signs, notarizes, and validates an accepted artifact", async () => { + const { run } = await createFixture(); + const result = run(); + expect({ exitCode: result.exitCode, stderr: result.stderr.toString() }).toEqual({ + exitCode: 0, + stderr: "", + }); + }); + + test("rejects a non-Accepted notarization status", async () => { + const { run } = await createFixture(); + expect( + run("sign-and-notarize", "arm64", { STUB_NOTARY_JSON: '{"status":"Rejected"}' }).exitCode, + ).not.toBe(0); + }); + + test("rejects malformed notarization output", async () => { + const { run } = await createFixture(); + expect(run("sign-and-notarize", "arm64", { STUB_NOTARY_JSON: "not-json" }).exitCode).not.toBe( + 0, + ); + }); + + test("rejects an artifact without a valid signature", async () => { + const { run } = await createFixture(); + expect(run("verify", "arm64", { STUB_SIGNED: "0" }).exitCode).not.toBe(0); + }); + + test("rejects a signer from the wrong Team ID", async () => { + const { run } = await createFixture(); + expect(run("verify", "arm64", { STUB_TEAM: "OTHERTEAM1" }).exitCode).not.toBe(0); + }); + + test("rejects an artifact with the wrong architecture", async () => { + const { run } = await createFixture(); + expect(run("verify", "x86_64", { STUB_ARCHES: "arm64" }).exitCode).not.toBe(0); + }); + + test("keeps both macOS architectures on the mandatory pre-publication path", async () => { + const release = await readFile(join(root, "scripts/release.sh"), "utf8"); + expect(release).toContain('"macos-arm64|bun-darwin-arm64|macos|-"'); + expect(release).toContain('"macos-x64|bun-darwin-x64|macos|-"'); + expect(release).toContain('[ "$kind" != macos ] && [ -f "$tarball" ]'); + + const signing = release.indexOf('"$MACOS_RELEASE_HELPER" sign-and-notarize'); + const extraction = release.indexOf('tar -xzf "$tarball"'); + const checksum = release.indexOf('shasum -a 256 "$pkg.tar.gz"'); + const publication = release.indexOf('step "Land release commit'); + expect(signing).toBeGreaterThan(0); + expect(extraction).toBeGreaterThan(signing); + expect(checksum).toBeGreaterThan(extraction); + expect(publication).toBeGreaterThan(checksum); + expect(release.slice(0, publication)).toContain( + '[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"', + ); + }); +}); From 9759b0babf638450c830460d02bf3b1a4789061a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 00:04:37 -0700 Subject: [PATCH 2/5] Fix signed macOS native runtime smoke --- docs/RELEASING.md | 8 ++++--- scripts/macos-entitlements.plist | 2 ++ scripts/macos-sign-and-notarize.sh | 9 +++---- scripts/release.sh | 15 +++++++++++- src/index.ts | 6 +++++ src/release-native-smoke.ts | 8 +++++++ tests/unit/macos-release-signing.test.ts | 30 +++++++++++++++++------- 7 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 src/release-native-smoke.ts diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b2452f48..4b6e0f54 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -3,7 +3,8 @@ 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, -notarized, and validated from their final tarballs. +smoke-tested through the shipped OpenTUI native library, notarized, and validated +from their final tarballs. ## Apple provisioning @@ -39,8 +40,9 @@ 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, notarization, tarball extraction, signature checks, -entitlement comparison, architecture checks, or Gatekeeper assessment. The +skip builds, signing, the post-sign OpenTUI native-library smoke, notarization, +tarball extraction, signature checks, entitlement comparison, architecture checks, +or Gatekeeper assessment. 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 diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist index 26b12f28..48f7bf5c 100644 --- a/scripts/macos-entitlements.plist +++ b/scripts/macos-entitlements.plist @@ -6,5 +6,7 @@ com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.disable-library-validation + diff --git a/scripts/macos-sign-and-notarize.sh b/scripts/macos-sign-and-notarize.sh index 55c18eff..c5283fa9 100755 --- a/scripts/macos-sign-and-notarize.sh +++ b/scripts/macos-sign-and-notarize.sh @@ -7,13 +7,13 @@ fail() { exit 1 } -[ "$#" -eq 3 ] || fail "usage: $0 sign-and-notarize|verify ARTIFACT arm64|x86_64" +[ "$#" -eq 3 ] || fail "usage: $0 sign|notarize|verify ARTIFACT arm64|x86_64" operation=$1 artifact=$2 expected_arch=$3 case "$operation" in - sign-and-notarize|verify) ;; + sign|notarize|verify) ;; *) fail "unknown operation: $operation" ;; esac case "$expected_arch" in @@ -70,11 +70,12 @@ verify_artifact() { fi } -if [ "$operation" = sign-and-notarize ]; then +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" diff --git a/scripts/release.sh b/scripts/release.sh index 0e1e168b..a2b95dd8 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -169,6 +169,17 @@ smoke_bin() { # smoke_bin LABEL BINARY return 0 } +smoke_native_bin() { # smoke_native_bin LABEL BINARY + local label=$1 bin=$2 + local host; host=$(host_label) + [ -n "$host" ] || return 0 + [ "$label" = "$host" ] || return 0 + info "smoke-testing signed OpenTUI native library for $label" + [ -x "$bin" ] || die "native smoke: $label binary is not executable" + "$bin" --__release_native_smoke__ >/dev/null 2>&1 \ + || die "native smoke: signed $label binary could not initialize OpenTUI native library" +} + # tar a tree with root ownership (for reproducible .deb payloads). GNU tar and # bsdtar spell the ownership override differently. tar_root() { # tar_root OUTPUT.tgz DIR PATH... @@ -372,7 +383,9 @@ for entry in "${TARGETS[@]}"; do macos-x64) macos_arch=x86_64 ;; *) die "unknown macOS release architecture: $label" ;; esac - "$MACOS_RELEASE_HELPER" sign-and-notarize "$STAGE/$pkg/$FORMULA" "$macos_arch" + "$MACOS_RELEASE_HELPER" sign "$STAGE/$pkg/$FORMULA" "$macos_arch" + smoke_native_bin "$label" "$STAGE/$pkg/$FORMULA" + "$MACOS_RELEASE_HELPER" notarize "$STAGE/$pkg/$FORMULA" "$macos_arch" fi tar -C "$STAGE" -czf "$tarball" "$pkg" if [ "$kind" = macos ]; then diff --git a/src/index.ts b/src/index.ts index b34e8f10..0aca4a20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { getTelemetry, setTelemetry } from "./telemetry/singleton.js"; import { runExec } from "./exec/runner.js"; import { runOnboarding } from "./tui/onboarding.js"; import { runTUI } from "./tui/runner.js"; +import { smokeOpenTUINativeLibrary } from "./release-native-smoke.js"; export interface Runners { runTUI: (config: import("./config/index.js").Config) => Promise; @@ -287,6 +288,11 @@ export function installSignalHandlers(): void { } if (import.meta.main) { + if (process.argv[2] === "--__release_native_smoke__") { + smokeOpenTUINativeLibrary(); + process.exit(0); + } + installCrashHandlers(); installSignalHandlers(); diff --git a/src/release-native-smoke.ts b/src/release-native-smoke.ts new file mode 100644 index 00000000..7c136f8b --- /dev/null +++ b/src/release-native-smoke.ts @@ -0,0 +1,8 @@ +import { CliRenderer } from "@opentui/core"; + +export function smokeOpenTUINativeLibrary(): void { + const renderer = new CliRenderer(process.stdin, process.stdout, 1, 1, { + useThread: false, + }); + renderer.destroy(); +} diff --git a/tests/unit/macos-release-signing.test.ts b/tests/unit/macos-release-signing.test.ts index ce412fb6..0b4b2125 100644 --- a/tests/unit/macos-release-signing.test.ts +++ b/tests/unit/macos-release-signing.test.ts @@ -72,10 +72,22 @@ afterEach(async () => { }); describe("macOS release signing gate", () => { + test("permits OpenTUI's shipped native library under hardened runtime", async () => { + const releaseEntitlements = await readFile(entitlements, "utf8"); + expect(releaseEntitlements).toContain( + "com.apple.security.cs.disable-library-validation\n\t", + ); + }); + test("signs, notarizes, and validates an accepted artifact", async () => { const { run } = await createFixture(); - const result = run(); - expect({ exitCode: result.exitCode, stderr: result.stderr.toString() }).toEqual({ + const signed = run("sign"); + expect({ exitCode: signed.exitCode, stderr: signed.stderr.toString() }).toEqual({ + exitCode: 0, + stderr: "", + }); + const notarized = run("notarize"); + expect({ exitCode: notarized.exitCode, stderr: notarized.stderr.toString() }).toEqual({ exitCode: 0, stderr: "", }); @@ -84,15 +96,13 @@ describe("macOS release signing gate", () => { test("rejects a non-Accepted notarization status", async () => { const { run } = await createFixture(); expect( - run("sign-and-notarize", "arm64", { STUB_NOTARY_JSON: '{"status":"Rejected"}' }).exitCode, + run("notarize", "arm64", { STUB_NOTARY_JSON: '{"status":"Rejected"}' }).exitCode, ).not.toBe(0); }); test("rejects malformed notarization output", async () => { const { run } = await createFixture(); - expect(run("sign-and-notarize", "arm64", { STUB_NOTARY_JSON: "not-json" }).exitCode).not.toBe( - 0, - ); + expect(run("notarize", "arm64", { STUB_NOTARY_JSON: "not-json" }).exitCode).not.toBe(0); }); test("rejects an artifact without a valid signature", async () => { @@ -116,12 +126,16 @@ describe("macOS release signing gate", () => { expect(release).toContain('"macos-x64|bun-darwin-x64|macos|-"'); expect(release).toContain('[ "$kind" != macos ] && [ -f "$tarball" ]'); - const signing = release.indexOf('"$MACOS_RELEASE_HELPER" sign-and-notarize'); + const signing = release.indexOf('"$MACOS_RELEASE_HELPER" sign '); + const nativeSmoke = release.indexOf('smoke_native_bin "$label"'); + const notarization = release.indexOf('"$MACOS_RELEASE_HELPER" notarize '); const extraction = release.indexOf('tar -xzf "$tarball"'); const checksum = release.indexOf('shasum -a 256 "$pkg.tar.gz"'); const publication = release.indexOf('step "Land release commit'); expect(signing).toBeGreaterThan(0); - expect(extraction).toBeGreaterThan(signing); + expect(nativeSmoke).toBeGreaterThan(signing); + expect(notarization).toBeGreaterThan(nativeSmoke); + expect(extraction).toBeGreaterThan(notarization); expect(checksum).toBeGreaterThan(extraction); expect(publication).toBeGreaterThan(checksum); expect(release.slice(0, publication)).toContain( From fc9405f9cc81ada6f1318cc2c3c4db8afb7407cd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 00:51:32 -0700 Subject: [PATCH 3/5] Require host-native smoke and lockfile-verified OpenTUI fetches Opposite-arch macOS binaries keep signature and notarization gates but no longer count as native-smoked. Cross-compile OpenTUI downloads now fail closed on bun.lock integrity mismatch before unpack. --- docs/RELEASING.md | 13 +- scripts/fetch-opentui-native.sh | 51 ++++++ scripts/macos-host-native-smoke.sh | 42 +++++ scripts/release.sh | 42 ++--- tests/unit/macos-release-signing.test.ts | 5 +- tests/unit/release-native-validation.test.ts | 155 +++++++++++++++++++ 6 files changed, 284 insertions(+), 24 deletions(-) create mode 100755 scripts/fetch-opentui-native.sh create mode 100755 scripts/macos-host-native-smoke.sh create mode 100644 tests/unit/release-native-validation.test.ts diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4b6e0f54..98ff2edc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -3,8 +3,10 @@ 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, -smoke-tested through the shipped OpenTUI native library, notarized, and validated -from their final tarballs. +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. ## Apple provisioning @@ -40,10 +42,11 @@ 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 OpenTUI native-library smoke, notarization, +skip builds, signing, the post-sign host-native OpenTUI smoke, notarization, tarball extraction, signature checks, entitlement comparison, architecture checks, -or Gatekeeper assessment. The -script creates a local version commit and tag, so use a disposable branch and +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. diff --git a/scripts/fetch-opentui-native.sh b/scripts/fetch-opentui-native.sh new file mode 100755 index 00000000..3dfeac05 --- /dev/null +++ b/scripts/fetch-opentui-native.sh @@ -0,0 +1,51 @@ +#!/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 + +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" diff --git a/scripts/macos-host-native-smoke.sh b/scripts/macos-host-native-smoke.sh new file mode 100755 index 00000000..10eca56f --- /dev/null +++ b/scripts/macos-host-native-smoke.sh @@ -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" diff --git a/scripts/release.sh b/scripts/release.sh index a2b95dd8..54675284 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -89,6 +89,8 @@ ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) cd "$ROOT" STAGE="$ROOT/dist/release" MACOS_RELEASE_HELPER="$ROOT/scripts/macos-sign-and-notarize.sh" +MACOS_HOST_NATIVE_SMOKE="$ROOT/scripts/macos-host-native-smoke.sh" +FETCH_OPENTUI_NATIVE="$ROOT/scripts/fetch-opentui-native.sh" MAINTAINER="$(git config user.name) <$(git config user.email)>" step() { printf '\n\033[1;34m==>\033[0m \033[1m%s\033[0m\n' "$*"; } @@ -169,17 +171,6 @@ smoke_bin() { # smoke_bin LABEL BINARY return 0 } -smoke_native_bin() { # smoke_native_bin LABEL BINARY - local label=$1 bin=$2 - local host; host=$(host_label) - [ -n "$host" ] || return 0 - [ "$label" = "$host" ] || return 0 - info "smoke-testing signed OpenTUI native library for $label" - [ -x "$bin" ] || die "native smoke: $label binary is not executable" - "$bin" --__release_native_smoke__ >/dev/null 2>&1 \ - || die "native smoke: signed $label binary could not initialize OpenTUI native library" -} - # tar a tree with root ownership (for reproducible .deb payloads). GNU tar and # bsdtar spell the ownership override differently. tar_root() { # tar_root OUTPUT.tgz DIR PATH... @@ -233,9 +224,10 @@ EOF # Nothing is written to package.json: these are already declared there as # optionalDependencies, and this only makes the ones bun skipped present. fetch_native_modules() { - local version platform pkg dir url variants bun_target _label _kind _deb + local version platform pkg dir variants bun_target _label _kind _deb version=$(jq -r '.optionalDependencies["@opentui/core-darwin-arm64"] // empty' package.json) [ -n "$version" ] || die "no @opentui/core-* version in package.json optionalDependencies" + [ -x "$FETCH_OPENTUI_NATIVE" ] || die "OpenTUI native fetch helper is missing or not executable" for entry in "${TARGETS[@]}"; do IFS='|' read -r _label bun_target _kind _deb <<< "$entry" platform=${bun_target#bun-} @@ -245,11 +237,8 @@ fetch_native_modules() { [ "$_kind" = linux ] && variants="$variants core-$platform-musl" for pkg in $variants; do dir="node_modules/@opentui/$pkg" - [ -d "$dir" ] && continue - url="https://registry.npmjs.org/@opentui/$pkg/-/$pkg-$version.tgz" info "fetching @opentui/$pkg@$version (cross-compile target)" - mkdir -p "$dir" - curl -fsSL "$url" | tar -xz -C "$dir" --strip-components=1 \ + "$FETCH_OPENTUI_NATIVE" "$pkg" "$version" "$dir" "$ROOT/bun.lock" \ || die "could not fetch @opentui/$pkg@$version from the registry" done done @@ -258,9 +247,11 @@ fetch_native_modules() { # ---- preflight ------------------------------------------------------------- step "Preflight for $TAG" [ "$(uname -s)" = Darwin ] || die "releases must run on macOS" -for t in git gh bun jq ar tar shasum codesign ditto lipo plutil spctl xcrun; do command -v "$t" >/dev/null || die "missing tool: $t"; done +for t in git gh bun jq ar tar shasum openssl curl codesign ditto lipo plutil spctl xcrun; do command -v "$t" >/dev/null || die "missing tool: $t"; done xcrun --find notarytool >/dev/null 2>&1 || die "missing tool: notarytool" [ -x "$MACOS_RELEASE_HELPER" ] || die "macOS signing helper is missing or not executable" +[ -x "$MACOS_HOST_NATIVE_SMOKE" ] || die "macOS host-native smoke helper is missing or not executable" +[ -x "$FETCH_OPENTUI_NATIVE" ] || die "OpenTUI native fetch helper is missing or not executable" : "${MACOS_SIGNING_IDENTITY:?set MACOS_SIGNING_IDENTITY to the Developer ID Application certificate name}" : "${MACOS_TEAM_ID:?set MACOS_TEAM_ID to the expected Apple Team ID}" : "${MACOS_NOTARY_PROFILE:?set MACOS_NOTARY_PROFILE to the notarytool Keychain profile name}" @@ -358,6 +349,7 @@ fi step "Build standalone binaries and packages" mkdir -p "$STAGE" validated_macos=0 +native_smoked_macos=0 for entry in "${TARGETS[@]}"; do IFS='|' read -r label target kind debarch <<< "$entry" pkg="$FORMULA-$VERSION-$label" @@ -384,7 +376,20 @@ for entry in "${TARGETS[@]}"; do *) die "unknown macOS release architecture: $label" ;; esac "$MACOS_RELEASE_HELPER" sign "$STAGE/$pkg/$FORMULA" "$macos_arch" - smoke_native_bin "$label" "$STAGE/$pkg/$FORMULA" + smoke_rc=0 + "$MACOS_HOST_NATIVE_SMOKE" "$label" "$STAGE/$pkg/$FORMULA" || smoke_rc=$? + case "$smoke_rc" in + 0) + info "host-native OpenTUI smoke passed for $label" + native_smoked_macos=$((native_smoked_macos + 1)) + ;; + 2) + info "cross-compiled $label: signature and notarization gates only (no host-native smoke claim)" + ;; + *) + die "native smoke: signed $label binary could not initialize OpenTUI native library" + ;; + esac "$MACOS_RELEASE_HELPER" notarize "$STAGE/$pkg/$FORMULA" "$macos_arch" fi tar -C "$STAGE" -czf "$tarball" "$pkg" @@ -421,6 +426,7 @@ for entry in "${TARGETS[@]}"; do rm -f "$STAGE/$FORMULA-$label.bin" done [ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation" +[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication" # ---- 4. land the release commit on main via PR, then tag ------------------ # A direct push to main is rejected by the branch ruleset ("N of N required diff --git a/tests/unit/macos-release-signing.test.ts b/tests/unit/macos-release-signing.test.ts index 0b4b2125..3118f83d 100644 --- a/tests/unit/macos-release-signing.test.ts +++ b/tests/unit/macos-release-signing.test.ts @@ -127,7 +127,7 @@ describe("macOS release signing gate", () => { expect(release).toContain('[ "$kind" != macos ] && [ -f "$tarball" ]'); const signing = release.indexOf('"$MACOS_RELEASE_HELPER" sign '); - const nativeSmoke = release.indexOf('smoke_native_bin "$label"'); + const nativeSmoke = release.indexOf('"$MACOS_HOST_NATIVE_SMOKE" "$label"'); const notarization = release.indexOf('"$MACOS_RELEASE_HELPER" notarize '); const extraction = release.indexOf('tar -xzf "$tarball"'); const checksum = release.indexOf('shasum -a 256 "$pkg.tar.gz"'); @@ -141,5 +141,8 @@ describe("macOS release signing gate", () => { expect(release.slice(0, publication)).toContain( '[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"', ); + expect(release.slice(0, publication)).toContain( + '[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"', + ); }); }); diff --git a/tests/unit/release-native-validation.test.ts b/tests/unit/release-native-validation.test.ts new file mode 100644 index 00000000..67507a44 --- /dev/null +++ b/tests/unit/release-native-validation.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dir, "../.."); +const release = join(root, "scripts/release.sh"); +const hostNativeSmoke = join(root, "scripts/macos-host-native-smoke.sh"); +const fetchOpentui = join(root, "scripts/fetch-opentui-native.sh"); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function createStubBinDirectory() { + const directory = await mkdtemp(join(tmpdir(), "corbits-release-native-")); + temporaryDirectories.push(directory); + const binDirectory = join(directory, "bin"); + await mkdir(binDirectory); + + const command = async (name: string, body: string) => { + const path = join(binDirectory, name); + await writeFile(path, `#!/bin/sh\nset -eu\n${body}\n`); + await chmod(path, 0o755); + }; + + return { directory, binDirectory, command }; +} + +describe("host-native signed OpenTUI smoke counting", () => { + test("release gate separates host-native smoke from opposite-arch signature validation", async () => { + const source = await readFile(release, "utf8"); + expect(source).toContain("native_smoked_macos"); + expect(source).toContain( + '[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"', + ); + expect(source).toContain( + '[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"', + ); + + const sign = source.indexOf('"$MACOS_RELEASE_HELPER" sign '); + const smoke = source.indexOf('"$MACOS_HOST_NATIVE_SMOKE" "$label"'); + const notarize = source.indexOf('"$MACOS_RELEASE_HELPER" notarize '); + const validated = source.indexOf("validated_macos=$((validated_macos + 1))"); + const nativeGate = source.indexOf( + '[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"', + ); + const publication = source.indexOf('step "Land release commit'); + expect(sign).toBeGreaterThan(0); + expect(smoke).toBeGreaterThan(sign); + expect(notarize).toBeGreaterThan(smoke); + expect(validated).toBeGreaterThan(notarize); + expect(nativeGate).toBeGreaterThan(validated); + expect(publication).toBeGreaterThan(nativeGate); + }); + + for (const host of [ + { machine: "arm64", hostLabel: "macos-arm64", opposite: "macos-x64" }, + { machine: "x86_64", hostLabel: "macos-x64", opposite: "macos-arm64" }, + ] as const) { + test(`on ${host.hostLabel} host, opposite-arch ${host.opposite} cannot pass native smoke without execution`, async () => { + const { directory, binDirectory, command } = await createStubBinDirectory(); + const artifact = join(directory, "corbits"); + const marker = join(directory, "executed"); + await writeFile(artifact, '#!/bin/sh\necho ran > "$NATIVE_SMOKE_MARKER"\n'); + await chmod(artifact, 0o755); + + await command( + "uname", + `case "$1" in -s) printf 'Darwin\\n' ;; -m) printf '${host.machine}\\n' ;; *) exit 1 ;; esac`, + ); + + const opposite = Bun.spawnSync({ + cmd: ["bash", hostNativeSmoke, host.opposite, artifact], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + NATIVE_SMOKE_MARKER: marker, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(opposite.exitCode).toBe(2); + expect(await Bun.file(marker).exists()).toBe(false); + + const matching = Bun.spawnSync({ + cmd: ["bash", hostNativeSmoke, host.hostLabel, artifact], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + NATIVE_SMOKE_MARKER: marker, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(matching.exitCode).toBe(0); + expect(await Bun.file(marker).exists()).toBe(true); + }); + } +}); + +describe("OpenTUI native package lockfile integrity", () => { + test("release fetch verifies bun.lock integrity before unpacking", async () => { + const source = await readFile(release, "utf8"); + expect(source).toContain("fetch-opentui-native.sh"); + expect(source).not.toContain('curl -fsSL "$url" | tar -xz -C "$dir"'); + expect(source).not.toContain('[ -d "$dir" ] && continue'); + }); + + test("mismatched checksum fails before unpack", async () => { + const { directory, binDirectory } = await createStubBinDirectory(); + const lockfile = join(directory, "bun.lock"); + const dest = join(directory, "node_modules/@opentui/core-darwin-arm64"); + const tarball = join(directory, "payload.tgz"); + await writeFile(tarball, "tampered-payload"); + await writeFile( + lockfile, + `{\n "packages": {\n "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", {}, "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="],\n }\n}\n`, + ); + await writeFile( + join(binDirectory, "curl"), + `#!/bin/sh +set -eu +out="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then out="$arg"; fi + prev="$arg" +done +[ -n "$out" ] +cp "${tarball}" "$out" +`, + ); + await chmod(join(binDirectory, "curl"), 0o755); + + const result = Bun.spawnSync({ + cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toMatch(/integrity|checksum|sha512/i); + expect(await Bun.file(join(dest, "package.json")).exists()).toBe(false); + }); +}); From efebd9774d413a7c4dd1687346566351854d0f61 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 01:06:56 -0700 Subject: [PATCH 4/5] Fix OpenTUI native integrity lockfile lookup bun.lock nests native package version pins under @opentui/core optionalDependencies on the same line as core's sha512. Matching the package name alone selected that wrong hash and broke cross-compile fetches. Require the packages-array entry shape so integrity checks the native tarball hash. --- CHANGELOG.md | 4 +- docs/RELEASING.md | 4 +- scripts/fetch-opentui-native.sh | 5 +- tests/unit/release-native-validation.test.ts | 97 ++++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3285791..dbf50bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - 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. + 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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 98ff2edc..01da3256 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,7 +6,9 @@ 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. +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 diff --git a/scripts/fetch-opentui-native.sh b/scripts/fetch-opentui-native.sh index 3dfeac05..a56819b2 100755 --- a/scripts/fetch-opentui-native.sh +++ b/scripts/fetch-opentui-native.sh @@ -28,7 +28,10 @@ for tool in curl openssl tar awk mkdir rm; do command -v "$tool" >/dev/null 2>&1 || fail "missing tool: $tool" done -integrity=$(awk -v key="\"@opentui/${pkg}\":" ' +# 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 diff --git a/tests/unit/release-native-validation.test.ts b/tests/unit/release-native-validation.test.ts index 67507a44..d77db167 100644 --- a/tests/unit/release-native-validation.test.ts +++ b/tests/unit/release-native-validation.test.ts @@ -152,4 +152,101 @@ cp "${tarball}" "$out" expect(result.stderr.toString()).toMatch(/integrity|checksum|sha512/i); expect(await Bun.file(join(dest, "package.json")).exists()).toBe(false); }); + + test("packages-array hash wins over nested optionalDependencies false match", async () => { + const { directory, binDirectory } = await createStubBinDirectory(); + const lockfile = join(directory, "bun.lock"); + const dest = join(directory, "node_modules/@opentui/core-darwin-arm64"); + const packageRoot = join(directory, "package"); + const tarball = join(directory, "core-darwin-arm64-0.5.1.tgz"); + await mkdir(packageRoot); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ name: "@opentui/core-darwin-arm64", version: "0.5.1" }), + ); + const packed = Bun.spawnSync({ + cmd: ["tar", "-czf", tarball, "-C", directory, "package"], + cwd: directory, + stdout: "pipe", + stderr: "pipe", + }); + expect(packed.exitCode).toBe(0); + + const digest = Bun.spawnSync({ + cmd: ["openssl", "dgst", "-sha512", "-binary", tarball], + stdout: "pipe", + stderr: "pipe", + }); + expect(digest.exitCode).toBe(0); + const packagesHash = `sha512-${Buffer.from(digest.stdout).toString("base64")}`; + const coreFalseMatchHash = + "sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg=="; + expect(packagesHash).not.toBe(coreFalseMatchHash); + + await writeFile( + lockfile, + `{ + "packages": { + "@opentui/core": ["@opentui/core@0.5.1", "", { "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.1", "@opentui/core-darwin-x64": "0.5.1" } }, "${coreFalseMatchHash}"], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "${packagesHash}"], + } +} +`, + ); + await writeFile( + join(binDirectory, "curl"), + `#!/bin/sh +set -eu +out="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then out="$arg"; fi + prev="$arg" +done +[ -n "$out" ] +cp "${tarball}" "$out" +`, + ); + await chmod(join(binDirectory, "curl"), 0o755); + + const accept = Bun.spawnSync({ + cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(accept.exitCode).toBe(0); + expect(await Bun.file(join(dest, "package.json")).exists()).toBe(true); + + await rm(dest, { recursive: true, force: true }); + const wrongPackagesHash = + "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + await writeFile( + lockfile, + `{ + "packages": { + "@opentui/core": ["@opentui/core@0.5.1", "", { "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.1" } }, "${packagesHash}"], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "${wrongPackagesHash}"], + } +} +`, + ); + const reject = Bun.spawnSync({ + cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(reject.exitCode).not.toBe(0); + expect(reject.stderr.toString()).toMatch(/integrity|checksum|sha512/i); + expect(await Bun.file(join(dest, "package.json")).exists()).toBe(false); + }); }); From 1e7077d495f0518ccc39684540e9d1b4ba4f7ac6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 09:04:53 -0700 Subject: [PATCH 5/5] Clean up macOS release hygiene tests --- tests/unit/macos-release-signing.test.ts | 30 +-------- tests/unit/release-native-validation.test.ts | 64 +++++++++----------- 2 files changed, 32 insertions(+), 62 deletions(-) diff --git a/tests/unit/macos-release-signing.test.ts b/tests/unit/macos-release-signing.test.ts index 3118f83d..497add58 100644 --- a/tests/unit/macos-release-signing.test.ts +++ b/tests/unit/macos-release-signing.test.ts @@ -45,7 +45,7 @@ printf '%s\\n' "$STUB_NOTARY_JSON"`, await command("ditto", `: > "$5"`); await command("plutil", `cp "$5" "$4"`); - const run = (operation = "sign-and-notarize", architecture = "arm64", overrides = {}) => + const run = (operation = "sign", architecture = "arm64", overrides = {}) => Bun.spawnSync({ cmd: ["bash", helper, operation, artifact, architecture], cwd: root, @@ -81,7 +81,7 @@ describe("macOS release signing gate", () => { test("signs, notarizes, and validates an accepted artifact", async () => { const { run } = await createFixture(); - const signed = run("sign"); + const signed = run(); expect({ exitCode: signed.exitCode, stderr: signed.stderr.toString() }).toEqual({ exitCode: 0, stderr: "", @@ -119,30 +119,4 @@ describe("macOS release signing gate", () => { const { run } = await createFixture(); expect(run("verify", "x86_64", { STUB_ARCHES: "arm64" }).exitCode).not.toBe(0); }); - - test("keeps both macOS architectures on the mandatory pre-publication path", async () => { - const release = await readFile(join(root, "scripts/release.sh"), "utf8"); - expect(release).toContain('"macos-arm64|bun-darwin-arm64|macos|-"'); - expect(release).toContain('"macos-x64|bun-darwin-x64|macos|-"'); - expect(release).toContain('[ "$kind" != macos ] && [ -f "$tarball" ]'); - - const signing = release.indexOf('"$MACOS_RELEASE_HELPER" sign '); - const nativeSmoke = release.indexOf('"$MACOS_HOST_NATIVE_SMOKE" "$label"'); - const notarization = release.indexOf('"$MACOS_RELEASE_HELPER" notarize '); - const extraction = release.indexOf('tar -xzf "$tarball"'); - const checksum = release.indexOf('shasum -a 256 "$pkg.tar.gz"'); - const publication = release.indexOf('step "Land release commit'); - expect(signing).toBeGreaterThan(0); - expect(nativeSmoke).toBeGreaterThan(signing); - expect(notarization).toBeGreaterThan(nativeSmoke); - expect(extraction).toBeGreaterThan(notarization); - expect(checksum).toBeGreaterThan(extraction); - expect(publication).toBeGreaterThan(checksum); - expect(release.slice(0, publication)).toContain( - '[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"', - ); - expect(release.slice(0, publication)).toContain( - '[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"', - ); - }); }); diff --git a/tests/unit/release-native-validation.test.ts b/tests/unit/release-native-validation.test.ts index d77db167..be1d4897 100644 --- a/tests/unit/release-native-validation.test.ts +++ b/tests/unit/release-native-validation.test.ts @@ -30,6 +30,23 @@ async function createStubBinDirectory() { return { directory, binDirectory, command }; } +async function stubCurlDownload( + command: (name: string, body: string) => Promise, + tarball: string, +) { + await command( + "curl", + `out="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then out="$arg"; fi + prev="$arg" +done +[ -n "$out" ] +cp "${tarball}" "$out"`, + ); +} + describe("host-native signed OpenTUI smoke counting", () => { test("release gate separates host-native smoke from opposite-arch signature validation", async () => { const source = await readFile(release, "utf8"); @@ -40,10 +57,15 @@ describe("host-native signed OpenTUI smoke counting", () => { expect(source).toContain( '[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"', ); + expect(source).toContain('"macos-arm64|bun-darwin-arm64|macos|-"'); + expect(source).toContain('"macos-x64|bun-darwin-x64|macos|-"'); + expect(source).toContain('[ "$kind" != macos ] && [ -f "$tarball" ]'); const sign = source.indexOf('"$MACOS_RELEASE_HELPER" sign '); const smoke = source.indexOf('"$MACOS_HOST_NATIVE_SMOKE" "$label"'); const notarize = source.indexOf('"$MACOS_RELEASE_HELPER" notarize '); + const extraction = source.indexOf('tar -xzf "$tarball"'); + const checksum = source.indexOf('shasum -a 256 "$pkg.tar.gz"'); const validated = source.indexOf("validated_macos=$((validated_macos + 1))"); const nativeGate = source.indexOf( '[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"', @@ -52,8 +74,10 @@ describe("host-native signed OpenTUI smoke counting", () => { expect(sign).toBeGreaterThan(0); expect(smoke).toBeGreaterThan(sign); expect(notarize).toBeGreaterThan(smoke); - expect(validated).toBeGreaterThan(notarize); - expect(nativeGate).toBeGreaterThan(validated); + expect(extraction).toBeGreaterThan(notarize); + expect(validated).toBeGreaterThan(extraction); + expect(checksum).toBeGreaterThan(validated); + expect(nativeGate).toBeGreaterThan(checksum); expect(publication).toBeGreaterThan(nativeGate); }); @@ -113,7 +137,7 @@ describe("OpenTUI native package lockfile integrity", () => { }); test("mismatched checksum fails before unpack", async () => { - const { directory, binDirectory } = await createStubBinDirectory(); + const { directory, binDirectory, command } = await createStubBinDirectory(); const lockfile = join(directory, "bun.lock"); const dest = join(directory, "node_modules/@opentui/core-darwin-arm64"); const tarball = join(directory, "payload.tgz"); @@ -122,21 +146,7 @@ describe("OpenTUI native package lockfile integrity", () => { lockfile, `{\n "packages": {\n "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", {}, "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="],\n }\n}\n`, ); - await writeFile( - join(binDirectory, "curl"), - `#!/bin/sh -set -eu -out="" -prev="" -for arg in "$@"; do - if [ "$prev" = "-o" ]; then out="$arg"; fi - prev="$arg" -done -[ -n "$out" ] -cp "${tarball}" "$out" -`, - ); - await chmod(join(binDirectory, "curl"), 0o755); + await stubCurlDownload(command, tarball); const result = Bun.spawnSync({ cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile], @@ -154,7 +164,7 @@ cp "${tarball}" "$out" }); test("packages-array hash wins over nested optionalDependencies false match", async () => { - const { directory, binDirectory } = await createStubBinDirectory(); + const { directory, binDirectory, command } = await createStubBinDirectory(); const lockfile = join(directory, "bun.lock"); const dest = join(directory, "node_modules/@opentui/core-darwin-arm64"); const packageRoot = join(directory, "package"); @@ -193,21 +203,7 @@ cp "${tarball}" "$out" } `, ); - await writeFile( - join(binDirectory, "curl"), - `#!/bin/sh -set -eu -out="" -prev="" -for arg in "$@"; do - if [ "$prev" = "-o" ]; then out="$arg"; fi - prev="$arg" -done -[ -n "$out" ] -cp "${tarball}" "$out" -`, - ); - await chmod(join(binDirectory, "curl"), 0o755); + await stubCurlDownload(command, tarball); const accept = Bun.spawnSync({ cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile],