Skip to content

Commit 11cc842

Browse files
committed
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.
1 parent 3366a5a commit 11cc842

6 files changed

Lines changed: 283 additions & 23 deletions

File tree

‎docs/RELEASING.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
Releases are operator-run from macOS with `scripts/release.sh`. The script builds
44
all four standalone targets and refuses to tag, publish a GitHub release, or
55
update the Homebrew tap unless both macOS binaries are freshly built, signed,
6-
smoke-tested through the shipped OpenTUI native library, notarized, and validated
7-
from their final tarballs.
6+
host-native OpenTUI-smoked on the release Mac, notarized, and validated from their
7+
final tarballs. Cross-compiled opposite-arch macOS binaries still require signature,
8+
notarization, and final-tarball verification, but they are never counted as
9+
host-native smoke.
810

911
## Apple provisioning
1012

@@ -40,10 +42,11 @@ scripts/release.sh X.Y.Z --no-push --skip-tap
4042
```
4143

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

‎scripts/fetch-opentui-native.sh‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Download one @opentui/core-* native package and unpack it only after the
4+
# tarball matches the sha512 integrity recorded in bun.lock.
5+
#
6+
# scripts/fetch-opentui-native.sh PACKAGE VERSION DEST_DIR [LOCKFILE]
7+
#
8+
# PACKAGE is the short name after @opentui/, e.g. core-darwin-arm64.
9+
10+
set -euo pipefail
11+
12+
fail() {
13+
printf 'OpenTUI native fetch failed: %s\n' "$1" >&2
14+
exit 1
15+
}
16+
17+
[ "$#" -eq 3 ] || [ "$#" -eq 4 ] || fail "usage: $0 PACKAGE VERSION DEST_DIR [LOCKFILE]"
18+
pkg=$1
19+
version=$2
20+
dest=$3
21+
lockfile=${4:-bun.lock}
22+
23+
[[ "$pkg" =~ ^core-[A-Za-z0-9_-]+$ ]] || fail "unsupported OpenTUI package name: $pkg"
24+
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]] || fail "invalid package version: $version"
25+
[ -f "$lockfile" ] || fail "lockfile not found: $lockfile"
26+
27+
for tool in curl openssl tar awk mkdir rm; do
28+
command -v "$tool" >/dev/null 2>&1 || fail "missing tool: $tool"
29+
done
30+
31+
integrity=$(awk -v key="\"@opentui/${pkg}\":" '
32+
index($0, key) && match($0, /"sha512-[^"]+"/) {
33+
print substr($0, RSTART + 1, RLENGTH - 2)
34+
exit
35+
}
36+
' "$lockfile")
37+
[ -n "$integrity" ] || fail "no bun.lock integrity for @opentui/$pkg"
38+
39+
temporary_directory=$(mktemp -d)
40+
trap 'rm -rf "$temporary_directory"' EXIT
41+
tarball="$temporary_directory/$pkg-$version.tgz"
42+
url="https://registry.npmjs.org/@opentui/$pkg/-/$pkg-$version.tgz"
43+
44+
curl -fsSL "$url" -o "$tarball" || fail "could not download @opentui/$pkg@$version"
45+
actual="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
46+
[ "$actual" = "$integrity" ] || fail "bun.lock integrity mismatch for @opentui/$pkg (checksum)"
47+
48+
rm -rf "$dest"
49+
mkdir -p "$dest"
50+
tar -xz -C "$dest" --strip-components=1 -f "$tarball" \
51+
|| fail "could not unpack @opentui/$pkg@$version"

‎scripts/macos-host-native-smoke.sh‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Host-native OpenTUI smoke for a signed macOS release binary.
4+
#
5+
# scripts/macos-host-native-smoke.sh LABEL BINARY
6+
#
7+
# Exit codes:
8+
# 0 — host architecture matched and the signed binary initialized OpenTUI
9+
# 2 — LABEL is not the host architecture (caller must not count native smoke)
10+
# 1 — host architecture matched but smoke failed
11+
#
12+
# Opposite-arch artifacts are never executed and never reported as smoked.
13+
14+
set -euo pipefail
15+
16+
fail() {
17+
printf 'macOS host-native smoke failed: %s\n' "$1" >&2
18+
exit 1
19+
}
20+
21+
[ "$#" -eq 2 ] || fail "usage: $0 LABEL BINARY"
22+
label=$1
23+
artifact=$2
24+
25+
host_label() {
26+
case "$(uname -s):$(uname -m)" in
27+
Darwin:arm64) echo macos-arm64 ;;
28+
Darwin:x86_64) echo macos-x64 ;;
29+
*) echo "" ;;
30+
esac
31+
}
32+
33+
host=$(host_label)
34+
[ -n "$host" ] || fail "host architecture is unrecognized; cannot run native smoke"
35+
if [ "$label" != "$host" ]; then
36+
exit 2
37+
fi
38+
39+
[ -f "$artifact" ] || fail "artifact does not exist"
40+
[ -x "$artifact" ] || fail "artifact is not executable"
41+
"$artifact" --__release_native_smoke__ >/dev/null 2>&1 \
42+
|| fail "signed $label binary could not initialize OpenTUI native library"

‎scripts/release.sh‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
8989
cd "$ROOT"
9090
STAGE="$ROOT/dist/release"
9191
MACOS_RELEASE_HELPER="$ROOT/scripts/macos-sign-and-notarize.sh"
92+
MACOS_HOST_NATIVE_SMOKE="$ROOT/scripts/macos-host-native-smoke.sh"
93+
FETCH_OPENTUI_NATIVE="$ROOT/scripts/fetch-opentui-native.sh"
9294
MAINTAINER="$(git config user.name) <$(git config user.email)>"
9395

9496
step() { printf '\n\033[1;34m==>\033[0m \033[1m%s\033[0m\n' "$*"; }
@@ -169,17 +171,6 @@ smoke_bin() { # smoke_bin LABEL BINARY
169171
return 0
170172
}
171173

172-
smoke_native_bin() { # smoke_native_bin LABEL BINARY
173-
local label=$1 bin=$2
174-
local host; host=$(host_label)
175-
[ -n "$host" ] || return 0
176-
[ "$label" = "$host" ] || return 0
177-
info "smoke-testing signed OpenTUI native library for $label"
178-
[ -x "$bin" ] || die "native smoke: $label binary is not executable"
179-
"$bin" --__release_native_smoke__ >/dev/null 2>&1 \
180-
|| die "native smoke: signed $label binary could not initialize OpenTUI native library"
181-
}
182-
183174
# tar a tree with root ownership (for reproducible .deb payloads). GNU tar and
184175
# bsdtar spell the ownership override differently.
185176
tar_root() { # tar_root OUTPUT.tgz DIR PATH...
@@ -233,9 +224,10 @@ EOF
233224
# Nothing is written to package.json: these are already declared there as
234225
# optionalDependencies, and this only makes the ones bun skipped present.
235226
fetch_native_modules() {
236-
local version platform pkg dir url variants bun_target _label _kind _deb
227+
local version platform pkg dir variants bun_target _label _kind _deb
237228
version=$(jq -r '.optionalDependencies["@opentui/core-darwin-arm64"] // empty' package.json)
238229
[ -n "$version" ] || die "no @opentui/core-* version in package.json optionalDependencies"
230+
[ -x "$FETCH_OPENTUI_NATIVE" ] || die "OpenTUI native fetch helper is missing or not executable"
239231
for entry in "${TARGETS[@]}"; do
240232
IFS='|' read -r _label bun_target _kind _deb <<< "$entry"
241233
platform=${bun_target#bun-}
@@ -246,10 +238,8 @@ fetch_native_modules() {
246238
for pkg in $variants; do
247239
dir="node_modules/@opentui/$pkg"
248240
[ -d "$dir" ] && continue
249-
url="https://registry.npmjs.org/@opentui/$pkg/-/$pkg-$version.tgz"
250241
info "fetching @opentui/$pkg@$version (cross-compile target)"
251-
mkdir -p "$dir"
252-
curl -fsSL "$url" | tar -xz -C "$dir" --strip-components=1 \
242+
"$FETCH_OPENTUI_NATIVE" "$pkg" "$version" "$dir" "$ROOT/bun.lock" \
253243
|| die "could not fetch @opentui/$pkg@$version from the registry"
254244
done
255245
done
@@ -258,9 +248,11 @@ fetch_native_modules() {
258248
# ---- preflight -------------------------------------------------------------
259249
step "Preflight for $TAG"
260250
[ "$(uname -s)" = Darwin ] || die "releases must run on macOS"
261-
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
251+
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
262252
xcrun --find notarytool >/dev/null 2>&1 || die "missing tool: notarytool"
263253
[ -x "$MACOS_RELEASE_HELPER" ] || die "macOS signing helper is missing or not executable"
254+
[ -x "$MACOS_HOST_NATIVE_SMOKE" ] || die "macOS host-native smoke helper is missing or not executable"
255+
[ -x "$FETCH_OPENTUI_NATIVE" ] || die "OpenTUI native fetch helper is missing or not executable"
264256
: "${MACOS_SIGNING_IDENTITY:?set MACOS_SIGNING_IDENTITY to the Developer ID Application certificate name}"
265257
: "${MACOS_TEAM_ID:?set MACOS_TEAM_ID to the expected Apple Team ID}"
266258
: "${MACOS_NOTARY_PROFILE:?set MACOS_NOTARY_PROFILE to the notarytool Keychain profile name}"
@@ -355,6 +347,7 @@ fi
355347
step "Build standalone binaries and packages"
356348
mkdir -p "$STAGE"
357349
validated_macos=0
350+
native_smoked_macos=0
358351
for entry in "${TARGETS[@]}"; do
359352
IFS='|' read -r label target kind debarch <<< "$entry"
360353
pkg="$FORMULA-$VERSION-$label"
@@ -381,7 +374,20 @@ for entry in "${TARGETS[@]}"; do
381374
*) die "unknown macOS release architecture: $label" ;;
382375
esac
383376
"$MACOS_RELEASE_HELPER" sign "$STAGE/$pkg/$FORMULA" "$macos_arch"
384-
smoke_native_bin "$label" "$STAGE/$pkg/$FORMULA"
377+
smoke_rc=0
378+
"$MACOS_HOST_NATIVE_SMOKE" "$label" "$STAGE/$pkg/$FORMULA" || smoke_rc=$?
379+
case "$smoke_rc" in
380+
0)
381+
info "host-native OpenTUI smoke passed for $label"
382+
native_smoked_macos=$((native_smoked_macos + 1))
383+
;;
384+
2)
385+
info "cross-compiled $label: signature and notarization gates only (no host-native smoke claim)"
386+
;;
387+
*)
388+
die "native smoke: signed $label binary could not initialize OpenTUI native library"
389+
;;
390+
esac
385391
"$MACOS_RELEASE_HELPER" notarize "$STAGE/$pkg/$FORMULA" "$macos_arch"
386392
fi
387393
tar -C "$STAGE" -czf "$tarball" "$pkg"
@@ -418,6 +424,7 @@ for entry in "${TARGETS[@]}"; do
418424
rm -f "$STAGE/$FORMULA-$label.bin"
419425
done
420426
[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"
427+
[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"
421428

422429
# ---- 4. land the release commit on main via PR, then tag ------------------
423430
# A direct push to main is rejected by the branch ruleset ("N of N required

‎tests/unit/macos-release-signing.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ describe("macOS release signing gate", () => {
127127
expect(release).toContain('[ "$kind" != macos ] && [ -f "$tarball" ]');
128128

129129
const signing = release.indexOf('"$MACOS_RELEASE_HELPER" sign ');
130-
const nativeSmoke = release.indexOf('smoke_native_bin "$label"');
130+
const nativeSmoke = release.indexOf('"$MACOS_HOST_NATIVE_SMOKE" "$label"');
131131
const notarization = release.indexOf('"$MACOS_RELEASE_HELPER" notarize ');
132132
const extraction = release.indexOf('tar -xzf "$tarball"');
133133
const checksum = release.indexOf('shasum -a 256 "$pkg.tar.gz"');
@@ -141,5 +141,8 @@ describe("macOS release signing gate", () => {
141141
expect(release.slice(0, publication)).toContain(
142142
'[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"',
143143
);
144+
expect(release.slice(0, publication)).toContain(
145+
'[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"',
146+
);
144147
});
145148
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join, resolve } from "node:path";
5+
6+
const root = resolve(import.meta.dir, "../..");
7+
const release = join(root, "scripts/release.sh");
8+
const hostNativeSmoke = join(root, "scripts/macos-host-native-smoke.sh");
9+
const fetchOpentui = join(root, "scripts/fetch-opentui-native.sh");
10+
const temporaryDirectories: string[] = [];
11+
12+
afterEach(async () => {
13+
await Promise.all(
14+
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
15+
);
16+
});
17+
18+
async function createStubBinDirectory() {
19+
const directory = await mkdtemp(join(tmpdir(), "corbits-release-native-"));
20+
temporaryDirectories.push(directory);
21+
const binDirectory = join(directory, "bin");
22+
await mkdir(binDirectory);
23+
24+
const command = async (name: string, body: string) => {
25+
const path = join(binDirectory, name);
26+
await writeFile(path, `#!/bin/sh\nset -eu\n${body}\n`);
27+
await chmod(path, 0o755);
28+
};
29+
30+
return { directory, binDirectory, command };
31+
}
32+
33+
describe("host-native signed OpenTUI smoke counting", () => {
34+
test("release gate separates host-native smoke from opposite-arch signature validation", async () => {
35+
const source = await readFile(release, "utf8");
36+
expect(source).toContain("native_smoked_macos");
37+
expect(source).toContain(
38+
'[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"',
39+
);
40+
expect(source).toContain(
41+
'[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"',
42+
);
43+
44+
const sign = source.indexOf('"$MACOS_RELEASE_HELPER" sign ');
45+
const smoke = source.indexOf('"$MACOS_HOST_NATIVE_SMOKE" "$label"');
46+
const notarize = source.indexOf('"$MACOS_RELEASE_HELPER" notarize ');
47+
const validated = source.indexOf("validated_macos=$((validated_macos + 1))");
48+
const nativeGate = source.indexOf(
49+
'[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"',
50+
);
51+
const publication = source.indexOf('step "Land release commit');
52+
expect(sign).toBeGreaterThan(0);
53+
expect(smoke).toBeGreaterThan(sign);
54+
expect(notarize).toBeGreaterThan(smoke);
55+
expect(validated).toBeGreaterThan(notarize);
56+
expect(nativeGate).toBeGreaterThan(validated);
57+
expect(publication).toBeGreaterThan(nativeGate);
58+
});
59+
60+
for (const host of [
61+
{ machine: "arm64", hostLabel: "macos-arm64", opposite: "macos-x64" },
62+
{ machine: "x86_64", hostLabel: "macos-x64", opposite: "macos-arm64" },
63+
] as const) {
64+
test(`on ${host.hostLabel} host, opposite-arch ${host.opposite} cannot pass native smoke without execution`, async () => {
65+
const { directory, binDirectory, command } = await createStubBinDirectory();
66+
const artifact = join(directory, "corbits");
67+
const marker = join(directory, "executed");
68+
await writeFile(artifact, '#!/bin/sh\necho ran > "$NATIVE_SMOKE_MARKER"\n');
69+
await chmod(artifact, 0o755);
70+
71+
await command(
72+
"uname",
73+
`case "$1" in -s) printf 'Darwin\\n' ;; -m) printf '${host.machine}\\n' ;; *) exit 1 ;; esac`,
74+
);
75+
76+
const opposite = Bun.spawnSync({
77+
cmd: ["bash", hostNativeSmoke, host.opposite, artifact],
78+
cwd: root,
79+
env: {
80+
...process.env,
81+
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
82+
NATIVE_SMOKE_MARKER: marker,
83+
},
84+
stdout: "pipe",
85+
stderr: "pipe",
86+
});
87+
expect(opposite.exitCode).toBe(2);
88+
expect(await Bun.file(marker).exists()).toBe(false);
89+
90+
const matching = Bun.spawnSync({
91+
cmd: ["bash", hostNativeSmoke, host.hostLabel, artifact],
92+
cwd: root,
93+
env: {
94+
...process.env,
95+
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
96+
NATIVE_SMOKE_MARKER: marker,
97+
},
98+
stdout: "pipe",
99+
stderr: "pipe",
100+
});
101+
expect(matching.exitCode).toBe(0);
102+
expect(await Bun.file(marker).exists()).toBe(true);
103+
});
104+
}
105+
});
106+
107+
describe("OpenTUI native package lockfile integrity", () => {
108+
test("release fetch verifies bun.lock integrity before unpacking", async () => {
109+
const source = await readFile(release, "utf8");
110+
expect(source).toContain("fetch-opentui-native.sh");
111+
expect(source).not.toContain('curl -fsSL "$url" | tar -xz -C "$dir"');
112+
});
113+
114+
test("mismatched checksum fails before unpack", async () => {
115+
const { directory, binDirectory } = await createStubBinDirectory();
116+
const lockfile = join(directory, "bun.lock");
117+
const dest = join(directory, "node_modules/@opentui/core-darwin-arm64");
118+
const tarball = join(directory, "payload.tgz");
119+
await writeFile(tarball, "tampered-payload");
120+
await writeFile(
121+
lockfile,
122+
`{\n "packages": {\n "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", {}, "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="],\n }\n}\n`,
123+
);
124+
await writeFile(
125+
join(binDirectory, "curl"),
126+
`#!/bin/sh
127+
set -eu
128+
out=""
129+
prev=""
130+
for arg in "$@"; do
131+
if [ "$prev" = "-o" ]; then out="$arg"; fi
132+
prev="$arg"
133+
done
134+
[ -n "$out" ]
135+
cp "${tarball}" "$out"
136+
`,
137+
);
138+
await chmod(join(binDirectory, "curl"), 0o755);
139+
140+
const result = Bun.spawnSync({
141+
cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile],
142+
cwd: root,
143+
env: {
144+
...process.env,
145+
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
146+
},
147+
stdout: "pipe",
148+
stderr: "pipe",
149+
});
150+
expect(result.exitCode).not.toBe(0);
151+
expect(result.stderr.toString()).toMatch(/integrity|checksum|sha512/i);
152+
expect(await Bun.file(join(dest, "package.json")).exists()).toBe(false);
153+
});
154+
});

0 commit comments

Comments
 (0)