diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 030329037..114a428c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -308,7 +308,7 @@ jobs: # resolve correctly; workflow-level env evaluates before the job's # environment: is applied. SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - # Set on main/release branches so build.ts runs binpunch + creates .gz + # Set on main/release branches so build.ts creates .gz archives RELEASE_BUILD: ${{ github.event_name != 'pull_request' && '1' || '' }} # Codesigning: only on main/release pushes (fork PRs lack secrets) FOSSILIZE_SIGN: ${{ github.event_name == 'push' && (github.ref_name == 'main' || startsWith(github.ref_name, 'release/')) && 'y' || 'n' }} @@ -350,6 +350,19 @@ jobs: echo "$OUTPUT" exit 1 fi + - name: Verify code signature (darwin) + # Only runs when signing was active (main/release pushes) and target is darwin. + # The smoke test alone does NOT catch invalid signatures — AMFI only SIGKILLs + # quarantined (downloaded) binaries, not freshly-built ones on the build machine. + if: >- + startsWith(matrix.target, 'darwin') && + github.event_name == 'push' && + (github.ref_name == 'main' || startsWith(github.ref_name, 'release/')) + shell: bash + run: | + BIN=./dist-bin/sentry-${{ matrix.target }} + echo "Verifying code signature on $BIN..." + rcodesign verify "$BIN" - name: Upload binary artifact uses: actions/upload-artifact@v7 with: diff --git a/.lore.md b/.lore.md index cc4dfc20e..141eada8e 100644 --- a/.lore.md +++ b/.lore.md @@ -8,7 +8,7 @@ * **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth token precedence in \`src/lib/db/auth.ts\`: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. \`runInteractiveLogin\` catches OAuth flow errors internally and returns falsy on failure; login command sets \`process.exitCode = 1\` and returns normally (does NOT reject). Tests expecting \`rejects.toThrow()\` will fail — assert via fetch-call inspection instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var. -* **Binary build pipeline: esbuild → fossilize → Node SEA (replacing Bun.build compile)**: Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node24 target] → dist-build/bin.js → \[fossilize --no-bundle] → Node SEA binary → \[binpunch ICU hole-punch] → gzip\`. Strip debug symbols handled INSIDE fossilize (as of fossilize 0.7.0) — fossilize strips the copied binary BEFORE postject injection. Strip MUST happen before injection — after SEA injection, \`strip\` fails ('section .text can't be allocated in segment 2'). macOS: \`strip -x\` on unsigned copy; cross-strip from Linux silently fails (caught). Windows: skipped (no debug symbols). NODE\_VERSION='lts'. ALL\_TARGETS: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64 + musl variants. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. UPX RULED OUT — destroys ELF notes. \`FOSSILIZE\_SIGN=y\` on push to main/release. Gzip only when \`RELEASE\_BUILD=1\`. \`stripCachedNodeBinaries()\` removed from \`script/build.ts\` — superseded by fossilize 0.7.0. +* **Binary build pipeline: esbuild → fossilize → Node SEA (replacing Bun.build compile)**: Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node24 target] → dist-build/bin.js → \[fossilize --no-bundle --hole-punch] → Node SEA binary → gzip\`. CRITICAL ORDER: hole-punch MUST happen BEFORE signing (issue #1033 — hole-punching after signing invalidates macOS code signature, causing AMFI SIGKILL). As of fossilize 0.8.0, hole-punch runs inside fossilize via \`--hole-punch\` flag (uses binpunch internally), between chmod and sign+notarize. Strip debug symbols also handled INSIDE fossilize (as of fossilize 0.7.0). macOS: \`strip -x\` on unsigned copy; cross-strip from Linux silently fails (caught). Windows: skipped. NODE\_VERSION='lts'. ALL\_TARGETS: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64 + musl variants. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. UPX RULED OUT — destroys ELF notes. \`FOSSILIZE\_SIGN=y\` on push to main/release. Gzip only when \`RELEASE\_BUILD=1\`. binpunch is opt-in via \`--hole-punch\` flag / \`FOSSILIZE\_HOLE\_PUNCH\` env var — NOT default-on (removes non-English ICU locale data, breaking i18n for consumers). Cache stays pristine; all mutations (strip, inject, hole-punch, sign) happen on per-build output copy. * **Binary size breakdown: 94.5% is Node.js runtime — bundled code is ~6.3 MiB**: Binary composition (linux-x64, Node 24 LTS): Node.js runtime=121 MiB (ships with debug symbols). \`strip --strip-unneeded\` → 99 MiB (-17 MiB raw, -4 MiB compressed). Strip built into fossilize 0.7.0 — happens on the copied binary BEFORE postject injection. After strip+SEA+binpunch: ~108 MiB raw, ~30 MiB gzip (vs 125 MiB / 34 MiB unstripped). .rodata=52.5 MB: V8 snapshot ~12 MB, ICU full-icu data ~28 MB. UPX compresses to 25 MiB but DESTROYS ELF notes — ruled out. \`--with-intl=small-icu\` saves ~26-28 MiB (biggest win from custom build); \`--without-lief\` BREAKS SEA; \`--without-sqlite\` BREAKS CLI; \`--disable-single-executable-application\` BREAKS EVERYTHING. Custom build deferred — poor cost/benefit (~3.5h build vs 5min fossilize). Final vs Bun: download 30 MiB (Bun: 32 MiB), \`--version\` ~1.0s (Bun: ~1.9s), completions ~150ms (Bun: ~180ms). diff --git a/package.json b/package.json index 4aebfa062..f2c2e7703 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,12 @@ "@types/react": "^19.2.14", "@types/semver": "^7.7.1", "@vitest/coverage-v8": "^4.1.7", - "binpunch": "^1.0.0", "chalk": "^5.6.2", "cli-highlight": "^2.1.11", "consola": "^3.4.2", "esbuild": "^0.25.0", "fast-check": "^4.5.3", - "fossilize": "^0.7.0", + "fossilize": "^0.8.0", "hono": "^4.12.15", "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", @@ -124,10 +123,5 @@ "check:stale-refs": "pnpm tsx script/check-stale-references.ts" }, "type": "module", - "types": "./dist/index.d.cts", - "patchedDependencies": { - "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch", - "@sentry/node-core@10.50.0": "patches/@sentry%2Fnode-core@10.50.0.patch", - "@sentry/core@10.50.0": "patches/@sentry%2Fcore@10.50.0.patch" - } + "types": "./dist/index.d.cts" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d3c5446f..0ee0356da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,9 +76,6 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.7 version: 4.1.7(vitest@4.1.7) - binpunch: - specifier: ^1.0.0 - version: 1.0.0 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -95,8 +92,8 @@ importers: specifier: ^4.5.3 version: 4.8.0 fossilize: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.0 + version: 0.8.0 hono: specifier: ^4.12.15 version: 4.12.18 @@ -1785,8 +1782,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fossilize@0.7.0: - resolution: {integrity: sha512-DMY/j38pRnMgAjz+2PzOGn0WlDQ4Aa5/gQMgrsBLSv1LK+eSH51IIyxjMEvonv1Xs9LpIdJIGlVVXPmZGJR8Qg==} + fossilize@0.8.0: + resolution: {integrity: sha512-1VxMP53DmiGPtSams28lsX0fbZYtt4wT+vQHF5txv5oE7PkH+6G6ihGuu4jeQF1vw6XVb2K7BngPjQaNJYdxag==} engines: {node: '>=18'} hasBin: true @@ -4625,10 +4622,11 @@ snapshots: forwarded@0.2.0: {} - fossilize@0.7.0: + fossilize@0.8.0: dependencies: '@stricli/auto-complete': 1.2.7 '@stricli/core': 1.2.7(patch_hash=10f8318359902742c80029abdcec31fe4c64de89b6f2a3f5afb09f05aacc506d) + binpunch: 1.0.0 esbuild: 0.25.12 macho-unsign: 2.0.6 p-limit: 6.2.0 diff --git a/script/build.ts b/script/build.ts index ec6aea41f..0ecc78e5b 100644 --- a/script/build.ts +++ b/script/build.ts @@ -33,7 +33,6 @@ import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { gzip } from "node:zlib"; -import { processBinary } from "binpunch"; import { build as esbuild } from "esbuild"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; @@ -321,6 +320,7 @@ async function compileAllTargets( [ fossilizeBin, "--no-bundle", + "--hole-punch", "--output-name", "sentry", "--platforms", @@ -342,7 +342,7 @@ async function compileAllTargets( return { successes: 0, failures: targets.length }; } - // Post-process each target: rename Windows binary, hole-punch ICU, gzip + // Post-process each target: rename Windows binary, gzip let successes = 0; let failures = 0; for (const target of targets) { @@ -361,10 +361,13 @@ async function compileAllTargets( /** * Post-process a single compiled binary: rename from fossilize's output - * naming to our expected naming, hole-punch ICU data, and optionally gzip. + * naming to our expected naming, and optionally gzip. * * Fossilize outputs `sentry-{os}-{arch}[.exe]` where os is "win" for Windows. * We rename "win" → "windows" to match our release naming convention. + * + * Note: ICU hole-punch now runs inside fossilize (--hole-punch flag) before + * code signing, so it's no longer done here. */ async function postProcessTarget(target: BuildTarget): Promise { const packageName = getPackageName(target); @@ -383,15 +386,6 @@ async function postProcessTarget(target: BuildTarget): Promise { console.log(` -> ${outfile}`); - // Hole-punch: zero unused ICU data entries so they compress to nearly nothing. - // Always runs so the smoke test exercises the same binary as the release. - const hpStats = processBinary(outfile); - if (hpStats && hpStats.removedEntries > 0) { - console.log( - ` -> hole-punched ${hpStats.removedEntries}/${hpStats.totalEntries} ICU entries` - ); - } - // On main and release branches (RELEASE_BUILD=1), create gzip-compressed // copies for release downloads / GHCR nightly (~70% smaller with hole-punch). if (process.env.RELEASE_BUILD) { diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 427276bc4..9ccac9801 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -432,7 +432,23 @@ async function spawnWithRetry( env, }); return await new Promise((resolve, reject) => { - proc.on("close", (code) => resolve(code ?? 1)); + proc.on("close", (code, signal) => { + // SIGKILL on macOS typically means AMFI killed the binary due to + // an invalid code signature (the downloaded binary has quarantine + // xattr). Surface this clearly instead of an opaque exit code. + if (signal === "SIGKILL") { + reject( + new UpgradeError( + "execution_failed", + "Downloaded binary was killed by the operating system (SIGKILL). " + + "This usually means the binary has an invalid code signature. " + + "Try reinstalling: curl -sL https://sentry.io/get-cli/ | bash" + ) + ); + return; + } + resolve(code ?? 1); + }); proc.on("error", (err) => reject(err)); }); } catch (error) {