Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 34 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -94,7 +92,7 @@ jobs:

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.3
bun-version: 1.4.4
Comment thread
joshavant marked this conversation as resolved.
Resolved

- run: pnpm install --frozen-lockfile

Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ 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<package.json version>` (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<package.json version>` (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
```

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.

Expand Down
8 changes: 6 additions & 2 deletions scripts/build-bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
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';

interface Options {
readonly target?: string;
Expand Down Expand Up @@ -79,7 +82,8 @@ export function createCompiledEntrypoint(projectRoot: string, version: string):
// main orchestrates the Bun compile flow for the mcporter binary.
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const projectRoot = path.join(fs.realpathSync(path.dirname(new URL(import.meta.url).pathname)), '..');
assertSupportedBunVersion(process.versions.bun ?? 'not running Bun');
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)) {
Expand All @@ -106,7 +110,7 @@ async function main(): Promise<void> {
}

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'}`);
Expand Down
6 changes: 5 additions & 1 deletion scripts/package-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/src/cli/generate/runtime.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" ]] || {
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions scripts/smoke-native-windows.ps1
Original file line number Diff line number Diff line change
@@ -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' }
9 changes: 9 additions & 0 deletions scripts/smoke-native.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 33 additions & 1 deletion scripts/test-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 \
Expand Down
4 changes: 3 additions & 1 deletion scripts/verify-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/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'));
const tag = process.argv[3];
const commit = process.argv[4];
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/generate/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ async function bundleWithBun({
runtimeKind: 'node' | 'bun';
minify: boolean;
}): Promise<string> {
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-'));
Expand Down
83 changes: 72 additions & 11 deletions src/cli/generate/runtime.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string> {
const bunBin = process.env.BUN_BIN ?? 'bun';
await new Promise<void>((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<string>((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<boolean> {
Expand Down
Loading
Loading