From f8562089b3f8ba862b46b7297dd09d72a4cd06f3 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:16:35 -0500 Subject: [PATCH 1/4] fix(security): build native MCPorter releases with Bun 1.4.4 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b3ad40a..de8e2e05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 if: runner.os == 'macOS' with: - bun-version: 1.4.3 + bun-version: 1.4.4 - name: Smoke-test both native release targets if: runner.os == 'macOS' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4466729..ed77a156 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.3 + bun-version: 1.4.4 - run: pnpm install --frozen-lockfile From bb20b6b6852023667a1c1a0302feaa3533430cfe Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:20:05 -0500 Subject: [PATCH 2/4] fix(release): reject affected Bun compilers and provenance --- scripts/build-bun.ts | 24 +++++++++++++++++++++++- scripts/package-release.sh | 6 +++++- scripts/test-release.sh | 34 +++++++++++++++++++++++++++++++++- scripts/verify-release.sh | 4 +++- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/scripts/build-bun.ts b/scripts/build-bun.ts index 7db180e2..e942c152 100644 --- a/scripts/build-bun.ts +++ b/scripts/build-bun.ts @@ -76,9 +76,31 @@ export function createCompiledEntrypoint(projectRoot: string, version: string): return [`process.env.MCPORTER_VERSION ??= ${embeddedVersion};`, `await import(${cliPath});`, ''].join('\n'); } +export function assertSupportedBunVersion(version: string): void { + const minimum: [number, number, number] = [1, 4, 4]; + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (match) { + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + const [minimumMajor, minimumMinor, minimumPatch] = minimum; + if ( + major > minimumMajor || + (major === minimumMajor && minor > minimumMinor) || + (major === minimumMajor && minor === minimumMinor && patch >= minimumPatch) + ) { + return; + } + } + throw new Error( + `Bun ${minimum.join('.')} or newer is required for native releases; found ${version}. Install a current stable Bun release and rebuild.` + ); +} + // main orchestrates the Bun compile flow for the mcporter binary. async function main(): Promise { const options = parseArgs(process.argv.slice(2)); + assertSupportedBunVersion(process.versions.bun ?? 'not running Bun'); const projectRoot = path.join(fs.realpathSync(path.dirname(new URL(import.meta.url).pathname)), '..'); const version = readPackageVersion(projectRoot); const distDir = path.join(projectRoot, 'dist-bun'); @@ -106,7 +128,7 @@ async function main(): Promise { } console.log(`Building mcporter binary → ${outputPath}`); - const result = spawnSync('bun', buildArgs, { stdio: 'inherit' }); + const result = spawnSync(process.execPath, buildArgs, { stdio: 'inherit' }); try { if (result.status !== 0) { throw new Error(`bun build exited with status ${result.status ?? 'unknown'}`); diff --git a/scripts/package-release.sh b/scripts/package-release.sh index f920a5a5..4ebc0821 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -39,6 +39,11 @@ for tool in bun git node pnpm shasum tar; do done cd "$ROOT" +bun_version=$(run_build bun --version) +node - "$bun_version" "$ROOT/scripts/build-bun.ts" <<'NODE' +const { assertSupportedBunVersion } = require(process.argv[3]); +assertSupportedBunVersion(process.argv[2]); +NODE release_version=${VERSION#v} package_version=$(node -p "require('./package.json').version") [[ "$package_version" == "$release_version" ]] || { @@ -119,7 +124,6 @@ for index in "${!architectures[@]}"; do tar -czf "$PAYLOAD/$archive_name" -C "$build_dir" mcporter done -bun_version=$(bun --version) node_version=$(node --version) pnpm_version=$(pnpm --version) host_version=$(sw_vers -productVersion) diff --git a/scripts/test-release.sh b/scripts/test-release.sh index 676fa30f..6a3aa8c2 100755 --- a/scripts/test-release.sh +++ b/scripts/test-release.sh @@ -92,7 +92,7 @@ cat >"$MOCK_BIN/bun" <<'MOCK' #!/usr/bin/env bash set -euo pipefail if [[ "${1:-}" == --version ]]; then - echo 1.3.14 + echo "${MOCK_BUN_VERSION:-1.4.4}" exit 0 fi output= @@ -120,6 +120,7 @@ if [[ "${1:-}" == --version ]]; then echo 10.33.2 exit 0 fi +printf 'pnpm %s\n' "$*" >>"$MOCK_LOG" if [[ "${1:-}" == clean ]]; then rm -rf "$MCPORTER_TEST_ROOT/dist" exit 0 @@ -374,6 +375,17 @@ assert_fails env \ "$ROOT/scripts/package-release.sh" "$TAG" [[ ! -e "$rejected_out" ]] || fail 'rejected notarization published release output' +# An affected compiler must stop before building, signing or publishing output. +: >"$MOCK_LOG" +old_bun_out="$WORK/old-bun-output" +assert_fails env \ + MOCK_BUN_VERSION=1.4.3 \ + MCPORTER_RELEASE_OUT_DIR="$old_bun_out" \ + MAC_RELEASE_CODESIGN_IDENTITY="$EXPECTED_IDENTITY" \ + NOTARYTOOL_KEYCHAIN_PROFILE=mock-profile \ + "$ROOT/scripts/package-release.sh" "$TAG" +[[ ! -e "$old_bun_out" && ! -s "$MOCK_LOG" ]] || fail 'affected Bun produced or signed release output' + # The happy path exercises the packager's default runner path, dual builds, # signing/notary calls, packaging, provenance, basename-only checksums, and the # token-free exact-inventory verifier. @@ -397,6 +409,26 @@ actual_inventory=$(find "$release_out" -mindepth 1 -maxdepth 1 -exec basename {} [[ "$actual_inventory" == "$expected_inventory" ]] || fail 'release output inventory mismatch' awk 'NF != 2 || $2 ~ /\// { exit 1 }' "$release_out/checksums.txt" || fail 'checksum contains a path' +# Self-consistent checksums cannot make an affected builder version acceptable. +old_bun_assets="$WORK/old-bun-assets" +cp -R "$release_out" "$old_bun_assets" +node - "$old_bun_assets" <<'NODE' +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const directory = process.argv[2]; +const provenancePath = path.join(directory, 'provenance.json'); +const provenance = JSON.parse(fs.readFileSync(provenancePath, 'utf8')); +provenance.builder.bun = '1.4.3'; +fs.writeFileSync(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`); +const digest = crypto.createHash('sha256').update(fs.readFileSync(provenancePath)).digest('hex'); +const checksumPath = path.join(directory, 'checksums.txt'); +fs.writeFileSync(checksumPath, fs.readFileSync(checksumPath, 'utf8').replace(/^[0-9a-f]{64} provenance\.json$/m, `${digest} provenance.json`)); +NODE +assert_fails env -u GH_TOKEN -u GITHUB_TOKEN \ + MCPORTER_VERIFY_EXEC_ARCH=all \ + "$ROOT/scripts/verify-release.sh" "$TAG" "$old_bun_assets" + touch "$release_out/unexpected.txt" assert_fails env -u GH_TOKEN -u GITHUB_TOKEN \ MCPORTER_VERIFY_EXEC_ARCH=all \ diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index a44f2d42..53873b26 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -85,8 +85,9 @@ awk 'NF != 2 || $1 !~ /^[[:xdigit:]]{64}$/ || $2 ~ /\// { exit 1 }' "$checksums" } (cd "$OUT_DIR" && shasum -a 256 -c checksums.txt) -node - "$OUT_DIR/provenance.json" "$VERSION" "$head_commit" "$checksums" <<'NODE' +node - "$OUT_DIR/provenance.json" "$VERSION" "$head_commit" "$checksums" "$ROOT/scripts/build-bun.ts" <<'NODE' const fs = require('node:fs'); +const { assertSupportedBunVersion } = require(process.argv[6]); const provenance = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); const tag = process.argv[3]; const commit = process.argv[4]; @@ -148,6 +149,7 @@ if ( ) { throw new Error('invalid release builder provenance'); } +assertSupportedBunVersion(provenance.builder.bun); for (const arch of ['arm64', 'x86_64']) { const name = `mcporter_${version}_darwin_${arch}.tar.gz`; const payload = payloads.get(name); From 4307fdcc994c16f9a021e97cd5d6ea004275e051 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:16:43 -0500 Subject: [PATCH 3/4] fix: admit the patched compiler before native generation --- scripts/build-bun.ts | 23 +----- scripts/package-release.sh | 2 +- scripts/verify-release.sh | 2 +- src/cli/generate/artifacts.ts | 2 +- src/cli/generate/runtime.ts | 83 +++++++++++++++++++--- tests/cli-generate-artifacts.test.ts | 20 +++++- tests/cli-generate-cli.integration.test.ts | 33 +++++++++ tests/fixtures/fake-bun.mjs | 10 ++- 8 files changed, 138 insertions(+), 37 deletions(-) diff --git a/scripts/build-bun.ts b/scripts/build-bun.ts index e942c152..b546fae0 100644 --- a/scripts/build-bun.ts +++ b/scripts/build-bun.ts @@ -8,6 +8,8 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { assertSupportedBunVersion } from '../src/cli/generate/runtime.js'; + interface Options { readonly target?: string; readonly bytecode: boolean; @@ -76,27 +78,6 @@ export function createCompiledEntrypoint(projectRoot: string, version: string): return [`process.env.MCPORTER_VERSION ??= ${embeddedVersion};`, `await import(${cliPath});`, ''].join('\n'); } -export function assertSupportedBunVersion(version: string): void { - const minimum: [number, number, number] = [1, 4, 4]; - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); - if (match) { - const major = Number(match[1]); - const minor = Number(match[2]); - const patch = Number(match[3]); - const [minimumMajor, minimumMinor, minimumPatch] = minimum; - if ( - major > minimumMajor || - (major === minimumMajor && minor > minimumMinor) || - (major === minimumMajor && minor === minimumMinor && patch >= minimumPatch) - ) { - return; - } - } - throw new Error( - `Bun ${minimum.join('.')} or newer is required for native releases; found ${version}. Install a current stable Bun release and rebuild.` - ); -} - // main orchestrates the Bun compile flow for the mcporter binary. async function main(): Promise { const options = parseArgs(process.argv.slice(2)); diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 4ebc0821..8da0fc87 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -40,7 +40,7 @@ done cd "$ROOT" bun_version=$(run_build bun --version) -node - "$bun_version" "$ROOT/scripts/build-bun.ts" <<'NODE' +node - "$bun_version" "$ROOT/src/cli/generate/runtime.ts" <<'NODE' const { assertSupportedBunVersion } = require(process.argv[3]); assertSupportedBunVersion(process.argv[2]); NODE diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 53873b26..47fa2e15 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -85,7 +85,7 @@ awk 'NF != 2 || $1 !~ /^[[:xdigit:]]{64}$/ || $2 ~ /\// { exit 1 }' "$checksums" } (cd "$OUT_DIR" && shasum -a 256 -c checksums.txt) -node - "$OUT_DIR/provenance.json" "$VERSION" "$head_commit" "$checksums" "$ROOT/scripts/build-bun.ts" <<'NODE' +node - "$OUT_DIR/provenance.json" "$VERSION" "$head_commit" "$checksums" "$ROOT/src/cli/generate/runtime.ts" <<'NODE' const fs = require('node:fs'); const { assertSupportedBunVersion } = require(process.argv[6]); const provenance = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); diff --git a/src/cli/generate/artifacts.ts b/src/cli/generate/artifacts.ts index eb0bd92b..ab08485d 100644 --- a/src/cli/generate/artifacts.ts +++ b/src/cli/generate/artifacts.ts @@ -129,9 +129,9 @@ async function bundleWithBun({ runtimeKind: 'node' | 'bun'; minify: boolean; }): Promise { + const bunBin = await verifyBunAvailable(); const absTarget = path.resolve(targetPath); await fs.mkdir(path.dirname(absTarget), { recursive: true }); - const bunBin = await verifyBunAvailable(); const tmpRoot = path.join(packageRoot, 'tmp'); await fs.mkdir(tmpRoot, { recursive: true }); const stagingDir = await fs.mkdtemp(path.join(tmpRoot, 'bundle-')); diff --git a/src/cli/generate/runtime.ts b/src/cli/generate/runtime.ts index 07767d75..03c11254 100644 --- a/src/cli/generate/runtime.ts +++ b/src/cli/generate/runtime.ts @@ -1,4 +1,27 @@ import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { devNull } from 'node:os'; + +export function assertSupportedBunVersion(version: string): void { + const minimum: [number, number, number] = [1, 4, 4]; + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (match) { + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + const [minimumMajor, minimumMinor, minimumPatch] = minimum; + if ( + major > minimumMajor || + (major === minimumMajor && minor > minimumMinor) || + (major === minimumMajor && minor === minimumMinor && patch >= minimumPatch) + ) { + return; + } + } + throw new Error( + `Bun ${minimum.join('.')} or newer is required for native builds; found ${version}. Install a current stable Bun release and rebuild.` + ); +} export async function resolveRuntimeKind( runtimeOption: 'node' | 'bun' | undefined, @@ -7,25 +30,63 @@ export async function resolveRuntimeKind( if (runtimeOption) { return runtimeOption; } - const bunAvailable = await isBunAvailable(); - if (compileOption && !bunAvailable) { - throw new Error('--compile requires Bun. Install Bun or set BUN_BIN to the bun executable.'); + if (compileOption) { + await verifyBunAvailable(); + return 'bun'; } + const bunAvailable = await isBunAvailable(); return bunAvailable ? 'bun' : 'node'; } export async function verifyBunAvailable(): Promise { const bunBin = process.env.BUN_BIN ?? 'bun'; - await new Promise((resolve, reject) => { - execFile(bunBin, ['--version'], { cwd: process.cwd(), env: process.env, windowsHide: true }, (error) => { - if (error) { - reject(new Error('Unable to locate Bun runtime. Install Bun or set BUN_BIN to the bun executable.')); - return; + return new Promise((resolve, reject) => { + execFile( + bunBin, + [ + `--config=${devNull}`, + '--no-env-file', + '--print', + 'JSON.stringify({version: process.versions.bun, executable: process.execPath})', + ], + { cwd: process.cwd(), env: process.env, windowsHide: true }, + (error, stdout) => { + if (error) { + reject( + new Error('Unable to locate Bun runtime. Install current stable Bun or set BUN_BIN to its executable.') + ); + return; + } + try { + const runtime: unknown = JSON.parse(stdout); + if ( + typeof runtime !== 'object' || + runtime === null || + !('version' in runtime) || + typeof runtime.version !== 'string' || + !('executable' in runtime) || + typeof runtime.executable !== 'string' || + !path.isAbsolute(runtime.executable) + ) { + throw new Error( + 'Unable to identify Bun runtime. Install current stable Bun or set BUN_BIN to its executable.' + ); + } + assertSupportedBunVersion(runtime.version); + resolve(runtime.executable); + } catch (metadataError) { + reject( + metadataError instanceof SyntaxError + ? new Error( + 'Unable to identify Bun runtime. Install current stable Bun or set BUN_BIN to its executable.', + { cause: metadataError } + ) + : metadataError + ); + } } - resolve(); - }); + ); }); - return bunBin; } async function isBunAvailable(): Promise { diff --git a/tests/cli-generate-artifacts.test.ts b/tests/cli-generate-artifacts.test.ts index 65da4c3d..46c98b5e 100644 --- a/tests/cli-generate-artifacts.test.ts +++ b/tests/cli-generate-artifacts.test.ts @@ -81,6 +81,25 @@ describe.skipIf(process.platform === 'win32')('Bun artifact generation', () => { await fsPromises.chmod(FAKE_BUN_PATH, 0o755); }); + it('rejects an affected compiler before creating a native artifact', async () => { + const tempDir = await fsPromises.mkdtemp(TMP_PREFIX); + const logPath = path.join(tempDir, 'bun.log'); + const outputPath = path.join(tempDir, 'compiled'); + vi.stubEnv('BUN_BIN', FAKE_BUN_PATH); + vi.stubEnv('FAKE_BUN_VERSION', '1.4.3'); + vi.stubEnv('FAKE_BUN_LOG', logPath); + try { + const bundlePath = path.join(tempDir, 'bundle.js'); + await fsPromises.writeFile(bundlePath, 'console.log("owned fixture");\n'); + await expect(compileBundleWithBun(bundlePath, outputPath)).rejects.toThrow('Bun 1.4.4 or newer is required'); + expect(fs.existsSync(outputPath)).toBe(false); + const invocations = await readInvocationLog(logPath); + expect(invocations.some((entry) => entry.args[0] === 'build')).toBe(false); + } finally { + await fsPromises.rm(tempDir, { recursive: true, force: true }); + } + }); + it.each([ { runtimeKind: 'node' as const, minify: false }, { runtimeKind: 'bun' as const, minify: true }, @@ -106,7 +125,6 @@ describe.skipIf(process.platform === 'win32')('Bun artifact generation', () => { expect(await fsPromises.readFile(result, 'utf8')).toContain('fake bun bundle'); expect((await fsPromises.stat(result)).mode & 0o111).not.toBe(0); const invocations = await readInvocationLog(logPath); - expect(invocations[0]?.args).toEqual(['--version']); const build = invocations.find((invocation) => invocation.args[0] === 'build'); expect(build?.args).toEqual([ 'build', diff --git a/tests/cli-generate-cli.integration.test.ts b/tests/cli-generate-cli.integration.test.ts index e8ef61d2..b524b537 100644 --- a/tests/cli-generate-cli.integration.test.ts +++ b/tests/cli-generate-cli.integration.test.ts @@ -612,6 +612,39 @@ await new Promise((resolve) => { transport.onclose = resolve; }); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); }, 20000); + it('does not run project preloads while detecting Bun', async () => { + if (!(await ensureBunSupport('Bun availability preload isolation'))) { + return; + } + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bun-preload-')); + const outputPath = path.join(tempDir, 'generated.ts'); + const markerPath = path.join(tempDir, 'preload-ran'); + try { + await fs.writeFile(path.join(tempDir, 'bunfig.toml'), 'preload = ["./preload.mjs"]\n'); + await fs.writeFile( + path.join(tempDir, 'preload.mjs'), + `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(markerPath)}, 'unexpected preload');\n` + ); + await new Promise((resolve, reject) => { + execFile( + process.execPath, + [CLI_ENTRY, 'generate-cli', '--command', baseUrl.toString(), '--output', outputPath], + { cwd: tempDir, env: { ...process.env, MCPORTER_NO_FORCE_EXIT: '1' } }, + (error) => (error ? reject(error) : resolve()) + ); + }); + expect(await fs.readFile(outputPath, 'utf8')).toMatch(/^#!\/usr\/bin\/env bun/); + expect( + await fs.access(markerPath).then( + () => true, + () => false + ) + ).toBe(false); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }, 20000); + it('runs "node dist/cli.js generate-cli --compile" when bun is available', async () => { if (!(await ensureRunnableBunCompile('compile integration test'))) { return; diff --git a/tests/fixtures/fake-bun.mjs b/tests/fixtures/fake-bun.mjs index 4be8455d..8f572b77 100755 --- a/tests/fixtures/fake-bun.mjs +++ b/tests/fixtures/fake-bun.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const args = process.argv.slice(2); const logPath = process.env.FAKE_BUN_LOG; @@ -10,7 +11,14 @@ if (logPath) { } if (args[0] === '--version') { - console.log('1.2.3-fake'); + console.log(process.env.FAKE_BUN_VERSION ?? '1.4.4'); + process.exit(0); +} + +if (args.includes('--print')) { + console.log( + JSON.stringify({ version: process.env.FAKE_BUN_VERSION ?? '1.4.4', executable: fileURLToPath(import.meta.url) }) + ); process.exit(0); } From 2138385af30bb0e89aa015efd1861dd144c1ba7d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Oct 2026 20:14:52 -0700 Subject: [PATCH 4/4] fix(release): qualify patched native builds on every platform Split the Windows handoff policy cases without increasing their timeout, resolve native build paths with fileURLToPath, and retain runtime-checked Windows and macOS rehearsal artifacts. Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> --- .github/workflows/release.yml | 37 +++++++++++++-- CHANGELOG.md | 1 + docs/RELEASE.md | 4 +- scripts/build-bun.ts | 3 +- scripts/smoke-native-windows.ps1 | 36 +++++++++++++++ scripts/smoke-native.sh | 9 ++++ tests/runtime-chrome-relay-handoff.test.ts | 52 +++++++++------------- 7 files changed, 104 insertions(+), 38 deletions(-) create mode 100644 scripts/smoke-native-windows.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed77a156..e4d2c789 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,14 +44,12 @@ jobs: - name: Validate dispatch source and version env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} RELEASE_TAG: ${{ inputs.tag }} WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail - expected_ref="refs/heads/$DEFAULT_BRANCH" - [[ "$GITHUB_REF" == "$expected_ref" ]] - [[ "$GITHUB_WORKFLOW_REF" == "$GITHUB_REPOSITORY/.github/workflows/release.yml@$expected_ref" ]] + [[ "$GITHUB_REF" == refs/heads/* ]] + [[ "$GITHUB_WORKFLOW_REF" == "$GITHUB_REPOSITORY/.github/workflows/release.yml@$GITHUB_REF" ]] [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" && "$GITHUB_SHA" == "$WORKFLOW_SHA" ]] node scripts/validate-release-metadata.mjs version=$(node -p 'require("./package.json").version') @@ -117,6 +115,37 @@ jobs: if-no-files-found: error retention-days: 7 + dry-run-windows: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + needs: dry-run-source + runs-on: windows-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + - run: pnpm install --frozen-lockfile + - name: Build and smoke-test Windows executable + shell: pwsh + run: ./scripts/smoke-native-windows.ps1 + - name: Retain Windows rehearsal archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mcporter-dry-run-windows-x64 + path: ${{ runner.temp }}/mcporter-dry-run-windows-x64.zip + if-no-files-found: error + retention-days: 7 + release: if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.dry_run) }} # DO NOT change this job to a self-hosted runner. diff --git a/CHANGELOG.md b/CHANGELOG.md index 98db62aa..54cef8ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [0.16.1] - Unreleased +- Require Bun 1.4.4 or newer for native MCPorter and generated CLI builds, rejecting affected compiler and release provenance versions; qualify Windows and both macOS architectures before release. Thanks @joshavant (#464). - Refresh jose and lint/format tooling while preserving Node 24 support and the 48-hour dependency cooldown; isolate daemon elicitation coverage from Windows process-query failures while retaining real process-retirement integration tests. ## [0.16.0] - 2026-10-20 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a241adef..996956db 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -27,7 +27,7 @@ v0.12.3's standalone arm64 binary is not a continuity baseline: it was ad-hoc `a ## 1. Credential-free preparation -The **Release** workflow defaults manual dispatches to `dry_run=true`. Run it from current `main`, optionally supplying `tag=v` (leave it blank to use the current version): +The **Release** workflow defaults manual dispatches to `dry_run=true`. Rehearse a same-repository PR branch before merging, then run it again from current `main`, optionally supplying `tag=v` (leave it blank to use the current version): ```bash gh workflow run release.yml --ref main -f dry_run=true -f tag=v0.14.3 @@ -35,7 +35,7 @@ gh workflow run release.yml --ref main -f dry_run=true -f tag=v0.14.3 The rehearsal validates the dispatch source and package metadata, runs `pnpm check` and `pnpm test`, builds and installs the packed npm archive into an isolated consumer, and checks CLI, library, and OAuth behavior. Native jobs use Apple Silicon `xcode-27` runners for both arm64 and baseline x86_64. They assert Rosetta support (installing it if missing), explicitly ad-hoc sign the Bun output, verify architecture and the macOS 13.0 deployment target, and smoke-test the extracted Intel binary with `arch -x86_64`. PR CI runs the same native smoke script for both targets. Rehearsal archives are retained as Actions artifacts for seven days. Existing version tags do not redirect the build to older source, and an already-published package version is allowed. -Both native jobs also run the release contract suite, which uses fixtures for Developer ID signing, notarization, provenance, and negative verification cases. A dry run has read-only repository permissions and no signing, notarization, npm, or Homebrew credentials. It creates no tags or GitHub Releases, publishes no npm packages, and dispatches no Homebrew updates. It does **not** replace the real local Developer ID/notarization gate or protected draft verification below. +Both macOS jobs also run the release contract suite, which uses fixtures for Developer ID signing, notarization, provenance, and negative verification cases. A Windows job builds and retains the baseline x64 executable. All three native lanes inspect the embedded Bun runtime, execute the extracted binary, and list an empty synthetic MCP configuration. A dry run has read-only repository permissions and no signing, notarization, npm, or Homebrew credentials. It creates no tags or GitHub Releases, publishes no npm packages, and dispatches no Homebrew updates. It does **not** replace the real local Developer ID/notarization gate or protected draft verification below. Windows rehearsal archives are qualification artifacts, not an additional official release channel. The shared `openclaw/release-workflows` npm archetype is not used here: mcporter publishes the exact npm archive already bound to both native verifier manifests and the locally signed macOS assets. Moving npm packing/publication to an independent archetype would break that shared artifact proof. The existing real-release chain remains authoritative. diff --git a/scripts/build-bun.ts b/scripts/build-bun.ts index b546fae0..127b2b0c 100644 --- a/scripts/build-bun.ts +++ b/scripts/build-bun.ts @@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { assertSupportedBunVersion } from '../src/cli/generate/runtime.js'; @@ -82,7 +83,7 @@ export function createCompiledEntrypoint(projectRoot: string, version: string): async function main(): Promise { const options = parseArgs(process.argv.slice(2)); assertSupportedBunVersion(process.versions.bun ?? 'not running Bun'); - const projectRoot = path.join(fs.realpathSync(path.dirname(new URL(import.meta.url).pathname)), '..'); + const projectRoot = path.join(fs.realpathSync(path.dirname(fileURLToPath(import.meta.url))), '..'); const version = readPackageVersion(projectRoot); const distDir = path.join(projectRoot, 'dist-bun'); if (!fs.existsSync(distDir)) { diff --git a/scripts/smoke-native-windows.ps1 b/scripts/smoke-native-windows.ps1 new file mode 100644 index 00000000..f2b0a62f --- /dev/null +++ b/scripts/smoke-native-windows.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$stage = Join-Path $env:RUNNER_TEMP 'native-windows-x64' +$smoke = Join-Path $env:RUNNER_TEMP 'smoke-windows-x64' +New-Item -ItemType Directory -Force $stage, $smoke | Out-Null +Set-Location $root +$version = (Get-Content package.json | ConvertFrom-Json).version +$binary = Join-Path $stage 'mcporter.exe' +pnpm build:bun --target bun-windows-x64-baseline --output $binary +if ($LASTEXITCODE -ne 0) { throw 'Windows native build failed' } +$archive = Join-Path $env:RUNNER_TEMP 'mcporter-dry-run-windows-x64.zip' +Compress-Archive -Path $binary -DestinationPath $archive -Force +Expand-Archive -Path $archive -DestinationPath $smoke -Force +$binary = Join-Path $smoke 'mcporter.exe' +$env:BUN_BE_BUN = '1' +try { + $metadata = & $binary --print 'JSON.stringify({bun: process.versions.bun, platform: process.platform, arch: process.arch})' + if ($LASTEXITCODE -ne 0) { throw 'Embedded runtime probe failed' } + $runtime = $metadata | ConvertFrom-Json + if ($runtime.bun -ne '1.4.4' -or $runtime.platform -ne 'win32' -or $runtime.arch -ne 'x64') { + throw "Unexpected embedded runtime: $metadata" + } + Write-Output "Embedded runtime: $metadata" +} finally { + Remove-Item Env:BUN_BE_BUN +} +Set-Location $smoke +$env:HOME = Join-Path $smoke 'home' +$env:USERPROFILE = $env:HOME +New-Item -ItemType Directory -Force $env:HOME | Out-Null +if ((& $binary --version) -ne $version -or $LASTEXITCODE -ne 0) { throw 'Native version mismatch' } +& $binary --help +if ($LASTEXITCODE -ne 0) { throw 'Native help failed' } +'{"mcpServers":{}}' | Set-Content -Path empty.json +& $binary --config (Join-Path $smoke 'empty.json') list --json +if ($LASTEXITCODE -ne 0) { throw 'Native synthetic config smoke failed' } diff --git a/scripts/smoke-native.sh b/scripts/smoke-native.sh index 6281f894..0dc930bf 100755 --- a/scripts/smoke-native.sh +++ b/scripts/smoke-native.sh @@ -30,10 +30,19 @@ tar -xzf "$archive" -C "$smoke" version=$(node -p 'require("./package.json").version') cd "$smoke" export HOME="$smoke/home" +printf '{"mcpServers":{}}\n' >"$smoke/empty.json" if [[ "$build_arch" == x86_64 ]]; then + runtime=$(BUN_BE_BUN=1 /usr/bin/arch -x86_64 ./mcporter --print 'process.versions.bun') + [[ "$runtime" == "$(bun --version)" ]] + echo "Embedded Bun ($build_arch): $runtime" [[ "$(/usr/bin/arch -x86_64 ./mcporter --version)" == "$version" ]] /usr/bin/arch -x86_64 ./mcporter --help + /usr/bin/arch -x86_64 ./mcporter --config "$smoke/empty.json" list --json else + runtime=$(BUN_BE_BUN=1 ./mcporter --print 'process.versions.bun') + [[ "$runtime" == "$(bun --version)" ]] + echo "Embedded Bun ($build_arch): $runtime" [[ "$(./mcporter --version)" == "$version" ]] ./mcporter --help + ./mcporter --config "$smoke/empty.json" list --json fi diff --git a/tests/runtime-chrome-relay-handoff.test.ts b/tests/runtime-chrome-relay-handoff.test.ts index 007b03de..1e8a6cc5 100644 --- a/tests/runtime-chrome-relay-handoff.test.ts +++ b/tests/runtime-chrome-relay-handoff.test.ts @@ -110,39 +110,29 @@ describe('runtime Chrome relay handoff lifecycle', () => { } }); - it('fails closed for both shared prefer and require when protected handoff setup fails', async () => { - const fixture = await createRelayFixture(); - authorizeBrokerDefinition(fixture.definition); - const blocker = path.join(fixture.directory, 'not-a-directory'); - await fs.writeFile(blocker, 'block'); - vi.spyOn(os, 'tmpdir').mockReturnValue(blocker); - let preferredArgs: readonly string[] = []; - const connectSpy = vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => { - preferredArgs = - ( - transport as unknown as { - _serverParams: { args?: readonly string[]; env?: Record }; - } - )._serverParams.args ?? []; - }); + it.each(['prefer', 'require'] as const)( + 'fails closed for shared %s when protected handoff setup fails', + async (policy) => { + const fixture = await createRelayFixture(); + authorizeBrokerDefinition(fixture.definition); + const blocker = path.join(fixture.directory, 'not-a-directory'); + await fs.writeFile(blocker, 'block'); + vi.spyOn(os, 'tmpdir').mockReturnValue(blocker); + const connectSpy = vi.spyOn(Client.prototype, 'connect'); - try { - await expect(createClientContext(fixture.definition, logger, clientInfo)).rejects.toMatchObject({ - name: 'ChromeDevtoolsRelayRequiredError', - decision: expect.objectContaining({ route: 'unavailable', reason: 'handoff-error' }), - }); - expect(preferredArgs).toEqual([]); - const requiredDefinition: ServerDefinition = { ...fixture.definition, chromeDevtoolsRelay: 'require' }; - await expect(createClientContext(requiredDefinition, logger, clientInfo)).rejects.toMatchObject({ - name: 'ChromeDevtoolsRelayRequiredError', - decision: expect.objectContaining({ route: 'unavailable', reason: 'handoff-error', policy: 'require' }), - }); - expect(connectSpy).not.toHaveBeenCalled(); - } finally { - await fixture.close(); - await fs.rm(fixture.directory, { recursive: true, force: true }); + try { + const definition: ServerDefinition = { ...fixture.definition, chromeDevtoolsRelay: policy }; + await expect(createClientContext(definition, logger, clientInfo)).rejects.toMatchObject({ + name: 'ChromeDevtoolsRelayRequiredError', + decision: expect.objectContaining({ route: 'unavailable', reason: 'handoff-error', policy: 'require' }), + }); + expect(connectSpy).not.toHaveBeenCalled(); + } finally { + await fixture.close(); + await fs.rm(fixture.directory, { recursive: true, force: true }); + } } - }); + ); }); interface SpawnShape {