From ff21c2e514972f1934156dba9c7770293af6f643 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 23:40:56 -0700 Subject: [PATCH 1/3] ci: publish npm and Homebrew from the GitHub Release event Adopts the fleet-standard automated release (acpx's guards, provenance via npm trusted publishing, CodexBar's tap dispatch) but triggers on release:published rather than tag push, so the locally signed and verified native artifacts keep their place ahead of publication. npm ships the exact verified Release tarball instead of repacking the checkout. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 304 +++++++++++++++++++++ docs/RELEASE.md | 30 +- scripts/test-release.sh | 17 +- scripts/validate-release-metadata.mjs | 31 +++ scripts/verify-published-release-proof.mjs | 86 ++++++ 5 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/validate-release-metadata.mjs create mode 100644 scripts/verify-published-release-proof.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..88552dc3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,304 @@ +name: Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Existing published release tag to publish (for example, v0.13.1)' + required: true + type: string + +concurrency: + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: 24 + PNPM_VERSION: 10.33.2 + +jobs: + release: + # DO NOT change this job to a self-hosted runner. + # npm trusted publishing + provenance for GitHub Actions releases + # requires a GitHub-hosted runner, and publishing fails on self-hosted + # environments with: + # "Unsupported GitHub Actions runner environment: self-hosted". + # Before the first run, configure mcporter's npmjs.com trusted publisher + # for openclaw/mcporter and .github/workflows/release.yml. No NPM_TOKEN is used. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Resolve real published release + id: release + shell: bash + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+[.][0-9]+[.][0-9]+$ ]] || { + echo "::error::Release tags must match vX.Y.Z; received ${RELEASE_TAG:-}." + exit 1 + } + + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + expected_ref="refs/heads/$DEFAULT_BRANCH" + expected_workflow_ref="$GITHUB_REPOSITORY/.github/workflows/release.yml@$expected_ref" + [[ "$GITHUB_REF" == "$expected_ref" ]] || { + echo "::error::Manual release publication must be dispatched from $expected_ref" + exit 1 + } + [[ "$GITHUB_WORKFLOW_REF" == "$expected_workflow_ref" ]] || { + echo "::error::Release workflow must come from $expected_workflow_ref" + exit 1 + } + fi + + release="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + [[ "$(jq -r '.tag_name' <<<"$release")" == "$RELEASE_TAG" ]] + [[ "$(jq -r '.draft' <<<"$release")" == false ]] || { + echo "::error::GitHub Release $RELEASE_TAG is still a draft." + exit 1 + } + [[ "$(jq -r '.prerelease' <<<"$release")" == false ]] || { + echo "::error::GitHub Release $RELEASE_TAG is a prerelease." + exit 1 + } + [[ "$(jq -r '.published_at // empty' <<<"$release")" != "" ]] || { + echo "::error::GitHub Release $RELEASE_TAG has not been published." + exit 1 + } + + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + echo "default_branch=$DEFAULT_BRANCH" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v7 + with: + ref: ${{ steps.release.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@v6.0.8 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + check-latest: true + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Validate package metadata for trusted publishing + run: node scripts/validate-release-metadata.mjs + + - name: Validate release tag + id: tag + shell: bash + env: + DEFAULT_BRANCH: ${{ steps.release.outputs.default_branch }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + git fetch --no-tags origin "$DEFAULT_BRANCH" --depth=1 + release_sha="$(git rev-parse "$RELEASE_TAG^{commit}")" + package_version="$(node -p "require('./package.json').version")" + expected_tag="v$package_version" + + [[ "$RELEASE_TAG" == "$expected_tag" ]] || { + echo "::error::Release tag $RELEASE_TAG does not match package.json version $package_version; expected $expected_tag." + exit 1 + } + git merge-base --is-ancestor "$release_sha" "origin/$DEFAULT_BRANCH" || { + echo "::error::Tagged commit $release_sha is not contained in origin/$DEFAULT_BRANCH." + exit 1 + } + + echo "sha=$release_sha" >> "$GITHUB_OUTPUT" + echo "version=$package_version" >> "$GITHUB_OUTPUT" + + - name: Ensure version is not already published + shell: bash + env: + PACKAGE_VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + error_log="$(mktemp)" + trap 'rm -f "$error_log"' EXIT + + if npm view "mcporter@$PACKAGE_VERSION" version >/dev/null 2>"$error_log"; then + echo "::error::mcporter@$PACKAGE_VERSION is already published on npm." + exit 1 + fi + if ! grep -Eq "E404|404 Not Found" "$error_log"; then + cat "$error_log" >&2 + exit 1 + fi + + echo "Publishing mcporter@$PACKAGE_VERSION" + + - name: Verify protected native proof and published assets + id: proof + shell: bash + env: + DEFAULT_BRANCH: ${{ steps.release.outputs.default_branch }} + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + RELEASE_SHA: ${{ steps.tag.outputs.sha }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + [[ -n "$GH_TOKEN" ]] || { + echo "::error::HOMEBREW_TAP_TOKEN is required to read protected verifier artifacts and dispatch Homebrew." + exit 1 + } + + proof_runs="$RUNNER_TEMP/native-proof-runs.json" + gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow release-assets.yml \ + --event workflow_dispatch \ + --branch "$DEFAULT_BRANCH" \ + --status success \ + --limit 100 \ + --json conclusion,databaseId,displayTitle,event,headBranch,headSha,workflowName \ + > "$proof_runs" + run_id="$(jq -r \ + --arg title "Verify release assets $RELEASE_TAG" \ + --arg sha "$RELEASE_SHA" \ + --arg branch "$DEFAULT_BRANCH" \ + '[.[] | select( + .conclusion == "success" and + .event == "workflow_dispatch" and + .displayTitle == $title and + .headBranch == $branch and + .headSha == $sha and + .workflowName == "Verify Release Assets" + )][0].databaseId // empty' "$proof_runs")" + [[ "$run_id" =~ ^[0-9]+$ ]] || { + echo "::error::No successful protected native verifier run matches $RELEASE_TAG at $RELEASE_SHA." + exit 1 + } + + proof_dir="$RUNNER_TEMP/native-proof" + asset_dir="$RUNNER_TEMP/release-assets" + mkdir -p "$proof_dir/arm64" "$proof_dir/x86_64" "$asset_dir" + gh run download "$run_id" \ + --repo "$GITHUB_REPOSITORY" \ + --name verified-assets-arm64 \ + --dir "$proof_dir/arm64" + gh run download "$run_id" \ + --repo "$GITHUB_REPOSITORY" \ + --name verified-assets-x86_64 \ + --dir "$proof_dir/x86_64" + + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release.json" + while IFS=$'\t' read -r asset_id asset_name; do + gh api \ + --header 'Accept: application/octet-stream' \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$asset_dir/$asset_name" + done < <(jq -r '.assets[] | [.id, .name] | @tsv' "$RUNNER_TEMP/release.json") + + node scripts/verify-published-release-proof.mjs \ + "$RUNNER_TEMP/release.json" \ + "$proof_dir/arm64/verified-assets.json" \ + "$proof_dir/x86_64/verified-assets.json" \ + "$asset_dir" \ + "$RELEASE_TAG" \ + "$RELEASE_SHA" + + echo "native_verifier_run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "npm_archive=$asset_dir/mcporter-${RELEASE_TAG#v}.tgz" >> "$GITHUB_OUTPUT" + + - name: Run source gates + run: | + pnpm check + pnpm test + + - name: Verify npm trusted publishing OIDC + shell: bash + run: | + set -euo pipefail + [[ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" && -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]] || { + echo "::error::GitHub OIDC is unavailable. Keep id-token: write and use a GitHub-hosted runner." + exit 1 + } + + oidc_response="$RUNNER_TEMP/npm-oidc.json" + if ! curl --fail --silent --show-error \ + --header "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:registry.npmjs.org" \ + --output "$oidc_response"; then + echo "::error::GitHub could not issue the OIDC token required by npm trusted publishing." + exit 1 + fi + jq -e '.value | strings | length > 0' "$oidc_response" >/dev/null || { + echo "::error::GitHub returned an invalid OIDC response for npm trusted publishing." + exit 1 + } + + - name: Publish verified npm archive with provenance + shell: bash + env: + NPM_ARCHIVE: ${{ steps.proof.outputs.npm_archive }} + NPM_CONFIG_IGNORE_SCRIPTS: 'true' + run: | + set -euo pipefail + if ! npm publish --access public --provenance "$NPM_ARCHIVE"; then + echo "::error::npm trusted publishing failed. Configure the mcporter package on npmjs.com with openclaw/mcporter and .github/workflows/release.yml; this workflow intentionally has no NPM_TOKEN." + exit 1 + fi + + - name: Verify immutable npm publication + shell: bash + env: + NPM_ARCHIVE: ${{ steps.proof.outputs.npm_archive }} + PACKAGE_VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + expected_integrity="sha512-$(openssl dgst -sha512 -binary "$NPM_ARCHIVE" | openssl base64 -A)" + registry_ready=0 + + for _ in {1..20}; do + registry_version="$(npm view "mcporter@$PACKAGE_VERSION" version 2>/dev/null || true)" + registry_integrity="$(npm view "mcporter@$PACKAGE_VERSION" dist.integrity 2>/dev/null || true)" + if [[ "$registry_version" == "$PACKAGE_VERSION" && "$registry_integrity" == "$expected_integrity" ]]; then + registry_ready=1 + break + fi + if [[ "$registry_version" == "$PACKAGE_VERSION" && -n "$registry_integrity" && "$registry_integrity" != "$expected_integrity" ]]; then + echo "::error::npm registry integrity does not match the protected GitHub Release tarball." + exit 1 + fi + sleep 3 + done + + [[ "$registry_ready" == 1 ]] || { + echo "::error::npm did not expose the verified release artifact before timeout." + exit 1 + } + [[ "$(npm view mcporter dist-tags.latest)" == "$PACKAGE_VERSION" ]] || { + echo "::error::npm latest does not point to $PACKAGE_VERSION." + exit 1 + } + + - name: Dispatch protected Homebrew update + shell: bash + env: + DEFAULT_BRANCH: ${{ steps.release.outputs.default_branch }} + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + NATIVE_VERIFIER_RUN_ID: ${{ steps.proof.outputs.native_verifier_run_id }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + [[ -n "$GH_TOKEN" ]] + gh workflow run update-homebrew-tap.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$DEFAULT_BRANCH" \ + -f tag="$RELEASE_TAG" \ + -f native_verifier_run_id="$NATIVE_VERIFIER_RUN_ID" diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 4daa6cc8..0b9700fc 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,5 +1,5 @@ --- -summary: 'Serialized release checklist for exact-tag native proof, GitHub, npm, and Homebrew publication.' +summary: 'Serialized release checklist for exact-tag native proof and automated npm and Homebrew publication.' read_when: - 'Cutting a release or updating release automation' --- @@ -22,6 +22,9 @@ v0.12.3's standalone arm64 binary is not a continuity baseline: it was ad-hoc `a - `.github/workflows/release-assets.yml` and `.github/workflows/update-homebrew-tap.yml` must be dispatched from the repository's current default branch. Both reject a mismatched workflow ref. - Release automation accepts stable `vMAJOR.MINOR.PATCH` tags only; prereleases require a separate dist-tag-aware contract before they can enter this pipeline. +> [!IMPORTANT] +> Before the first automated publication, configure npm trusted publishing for the `mcporter` package on npmjs.com with repository `openclaw/mcporter` and workflow `.github/workflows/release.yml`. The workflow deliberately has no `NPM_TOKEN`: it requires GitHub OIDC on a GitHub-hosted runner and fails with an actionable error when that path is unavailable. This npmjs.com setting cannot be verified from the repository. + ## 1. Credential-free preparation 1. Update `package.json` and `CHANGELOG.md`; contributor work must keep its changelog thanks and commit `Co-authored-by` trailer. @@ -71,23 +74,20 @@ Do not publish GitHub, npm, or Homebrew before both native jobs succeed. ## 4. Serialized publication -1. Publish the already-verified GitHub draft without changing its tag or asset inventory. -2. Publish npm only through the proof-gated phase: +1. Publish the already-verified GitHub draft without changing its tag or asset inventory. Publishing a real, non-prerelease GitHub Release triggers **Release**; pushing the tag alone does not publish npm. +2. **Release** checks out the exact tag on a GitHub-hosted Ubuntu runner, validates the package author and normalized repository URL, requires `vMAJOR.MINOR.PATCH` to equal `v${package.json.version}`, and proves the tagged commit is contained in `origin/main`. It rejects draft and prerelease releases and any version already present on npm. +3. Before npm publication, the workflow finds the successful **Verify Release Assets** run for the exact tag commit, downloads both architecture proof artifacts and every published release asset, and requires their IDs, sizes, and SHA-256 digests to match. It then runs `pnpm check` and `pnpm test` without weakening either gate. +4. With those proofs complete, the workflow confirms that GitHub OIDC is available and uses npm trusted publishing plus provenance to publish the exact verified `mcporter-.tgz` from the GitHub Release. It waits for npm to expose that immutable version, requires registry integrity to match the verified tarball, and requires `latest` to point to it. No Developer ID, notarization, or npm token enters Actions. +5. Only after npm verification succeeds, **Release** dispatches **Update Homebrew Tap** from the current default branch with the tag and resolved native verifier run ID. That existing workflow rechecks npm, the exact native proof SHA/title/workflow, both architecture manifests, and every published GitHub byte before dispatching the tap. It computes SHA-512 integrity from the verified GitHub npm tarball and requires npm `dist.integrity` plus `latest` to match; `HOMEBREW_TAP_TOKEN` is scoped only to proof access and dispatch/wait steps. - ```bash - NATIVE_VERIFIER_RUN_ID= ./scripts/release.sh publish-npm - ``` +A manual **Release** dispatch is a recovery fallback, not a way around the native gate. Dispatch it from the current default branch with `tag=v`; it accepts only an existing published, non-prerelease GitHub Release and repeats the same tag, `main`, native-proof, source-gate, npm, and Homebrew checks. Because npm versions are immutable, if npm succeeded but the downstream Homebrew dispatch failed, rerun **Update Homebrew Tap** directly with the same tag and recorded native verifier run ID instead of rerunning **Release**. - This repeats local native verification, checks the exact successful protected workflow run, downloads and cross-checks both architecture proof artifacts, re-downloads every published asset by REST ID, and requires every digest to match the protected draft proof. It publishes the exact verified npm tarball, tolerates registry propagation after a successful publish, and verifies immutable registry integrity before continuing. +Verify registry metadata after automation completes: -3. Verify registry metadata: - - ```bash - npm view mcporter@ version dist-tags.latest dist.tarball dist.integrity time - ./scripts/release.sh smoke - ``` - -4. Dispatch **Update Homebrew Tap** from the current default branch with `tag=v` and the same `native_verifier_run_id`. The workflow rechecks npm, the exact native proof SHA/title/workflow, both architecture manifests, and every published GitHub byte against the preserved proof. It computes SHA-512 integrity from the verified GitHub npm tarball and requires npm `dist.integrity` plus `latest` to match before dispatching. Its token is scoped only to those dispatch/wait steps. +```bash +npm view mcporter@ version dist-tags.latest dist.tarball dist.integrity time +./scripts/release.sh smoke +``` ## 5. Downstream verification and closeout diff --git a/scripts/test-release.sh b/scripts/test-release.sh index 1325f6d6..e15e646d 100755 --- a/scripts/test-release.sh +++ b/scripts/test-release.sh @@ -376,8 +376,9 @@ assert_fails env -u GH_TOKEN -u GITHUB_TOKEN \ "$ROOT/scripts/verify-release.sh" "$TAG" "$package_only_out" # Workflow and orchestration boundaries: protected current default branch, -# exact REST draft inventory, narrow token scope, and no one-shot publish path. +# exact REST inventories, narrow token scope, and serialized publication. release_workflow="$ROOT/.github/workflows/release-assets.yml" +publish_workflow="$ROOT/.github/workflows/release.yml" homebrew_workflow="$ROOT/.github/workflows/update-homebrew-tap.yml" assert_fails "$ROOT/scripts/package-release.sh" v1.2.3-rc.1 assert_fails "$ROOT/scripts/verify-release.sh" v1.2.3-rc.1 "$WORK/missing-prerelease-assets" @@ -403,6 +404,20 @@ grep -Eq 'arch: process.env.RELEASE_ARCH' "$release_workflow" ! grep -Eq 'secrets\.RELEASE_ASSET_TOKEN' "$release_workflow" || fail 'release verifier uses a persistent secret' ! grep -Eq 'gh release download' "$release_workflow" || fail 'release download bypasses exact REST lookup' +grep -Eq '^ release:$' "$publish_workflow" +grep -Eq '^ types: \[published\]$' "$publish_workflow" +grep -Eq '^ workflow_dispatch:$' "$publish_workflow" +grep -Fq 'npm publish --access public --provenance "$NPM_ARCHIVE"' "$publish_workflow" +grep -Fq 'pnpm check' "$publish_workflow" +grep -Fq 'pnpm test' "$publish_workflow" +grep -Fq 'id-token: write' "$publish_workflow" +grep -Fq 'ACTIONS_ID_TOKEN_REQUEST_URL' "$publish_workflow" +grep -Fq 'secrets.HOMEBREW_TAP_TOKEN' "$publish_workflow" +grep -Fq 'workflow run update-homebrew-tap.yml' "$publish_workflow" +grep -Fq 'verify-published-release-proof.mjs' "$publish_workflow" +grep -Fq 'validate-release-metadata.mjs' "$publish_workflow" +! grep -Eq 'secrets\.(NPM_TOKEN|NODE_AUTH_TOKEN)' "$publish_workflow" || fail 'automated release regained a persistent npm token' + ! grep -Eq '\bspctl\b' "$ROOT/scripts/codesign-native.sh" "$ROOT/scripts/verify-release.sh" || \ fail 'standalone CLI verification must not require raw-binary spctl success' grep -Eq -- '--requirements "=designated => \$REQUIREMENT"' "$ROOT/scripts/codesign-native.sh" diff --git a/scripts/validate-release-metadata.mjs b/scripts/validate-release-metadata.mjs new file mode 100644 index 00000000..ceac6f40 --- /dev/null +++ b/scripts/validate-release-metadata.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const expectedAuthor = 'OpenClaw'; +const expectedRepoUrl = 'https://github.com/openclaw/mcporter'; +const normalizeRepoUrl = (value) => + String(value ?? '') + .trim() + .replace(/^git\+/, '') + .replace(/\.git$/i, '') + .replace(/\/+$/, ''); +const actualRepoUrl = normalizeRepoUrl(pkg?.repository?.url); +const normalizedExpectedRepoUrl = normalizeRepoUrl(expectedRepoUrl); +const errors = []; + +if (actualRepoUrl !== normalizedExpectedRepoUrl) { + errors.push( + `package.json repository.url must resolve to ${normalizedExpectedRepoUrl}; found ${actualRepoUrl || ''}` + ); +} +if ((pkg?.author ?? '') !== expectedAuthor) { + errors.push(`package.json author must be exactly "${expectedAuthor}"; found "${pkg?.author ?? ''}"`); +} +if (errors.length > 0) { + for (const error of errors) console.error(error); + process.exit(1); +} + +console.log('Package metadata validated.'); diff --git a/scripts/verify-published-release-proof.mjs b/scripts/verify-published-release-proof.mjs new file mode 100644 index 00000000..292edda8 --- /dev/null +++ b/scripts/verify-published-release-proof.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const [releasePath, armProofPath, x86ProofPath, assetDirectory, tag, commit] = process.argv.slice(2); + +if (![releasePath, armProofPath, x86ProofPath, assetDirectory, tag, commit].every(Boolean)) { + throw new Error( + 'usage: verify-published-release-proof.mjs ' + ); +} + +if (!/^v\d+\.\d+\.\d+$/.test(tag)) { + throw new Error(`invalid stable release tag: ${tag}`); +} + +const release = JSON.parse(readFileSync(releasePath, 'utf8')); +const armProof = JSON.parse(readFileSync(armProofPath, 'utf8')); +const x86Proof = JSON.parse(readFileSync(x86ProofPath, 'utf8')); +const version = tag.slice(1); +const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0); +const expectedNames = [ + `mcporter_${version}_darwin_arm64.tar.gz`, + `mcporter_${version}_darwin_x86_64.tar.gz`, + `mcporter-${version}.tgz`, + 'checksums.txt', + 'provenance.json', +].toSorted(byCodeUnit); +const publishedAssets = [...(release.assets ?? [])].toSorted((a, b) => byCodeUnit(a.name, b.name)); + +if ( + release.tag_name !== tag || + release.draft !== false || + release.prerelease !== false || + typeof release.published_at !== 'string' || + JSON.stringify(publishedAssets.map((asset) => asset.name)) !== JSON.stringify(expectedNames) +) { + throw new Error('published GitHub Release metadata or asset inventory is invalid'); +} + +function validateProof(proof, arch) { + const assets = [...(proof.assets ?? [])].toSorted((a, b) => byCodeUnit(a.name, b.name)); + if ( + proof.schemaVersion !== 2 || + proof.arch !== arch || + proof.repository !== process.env.GITHUB_REPOSITORY || + proof.tag !== tag || + proof.commit !== commit || + proof.releaseId !== release.id || + JSON.stringify(assets.map((asset) => asset.name)) !== JSON.stringify(expectedNames) || + assets.length !== publishedAssets.length + ) { + throw new Error(`published release is not bound to the protected ${arch} native proof`); + } + return assets; +} + +const armAssets = validateProof(armProof, 'arm64'); +const x86Assets = validateProof(x86Proof, 'x86_64'); +const proofVector = (assets) => assets.map(({ id, name, size, sha256 }) => ({ id, name, size, sha256 })); + +if (JSON.stringify(proofVector(armAssets)) !== JSON.stringify(proofVector(x86Assets))) { + throw new Error('arm64 and x86_64 native proof artifacts disagree on the verified asset set'); +} + +for (let index = 0; index < armAssets.length; index += 1) { + const proofAsset = armAssets[index]; + const releaseAsset = publishedAssets[index]; + const assetPath = join(assetDirectory, proofAsset.name); + const size = statSync(assetPath).size; + const sha256 = createHash('sha256').update(readFileSync(assetPath)).digest('hex'); + + if ( + proofAsset.id !== releaseAsset.id || + proofAsset.name !== releaseAsset.name || + proofAsset.size !== releaseAsset.size || + size !== proofAsset.size || + sha256 !== proofAsset.sha256 + ) { + throw new Error(`published asset changed after native verification: ${proofAsset.name}`); + } +} + +console.log(`Verified published ${tag} against both protected native proof artifacts.`); From 76bbcc2c2e5f892f600bea09943bc59c97ccdc5d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 23:49:36 -0700 Subject: [PATCH 2/3] test: derive the stdio teardown bound from the escalation budget The bare 2.5s bound left only ~600ms over the 700/700/500ms SIGTERM/SIGKILL escalation, which flaked at 3.8s on a Windows runner where process-tree enumeration is slower. Derive it from the escalation constants with platform headroom; removing the SIGKILL escalation still fails the test. Co-Authored-By: Claude Fable 5 --- tests/runtime-stdio-close.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/runtime-stdio-close.test.ts b/tests/runtime-stdio-close.test.ts index 58b2ee8c..c7a8a8fb 100644 --- a/tests/runtime-stdio-close.test.ts +++ b/tests/runtime-stdio-close.test.ts @@ -19,6 +19,14 @@ afterEach(async () => { cleanupPids.clear(); }); +// closeStdioChild escalates SIGTERM -> SIGTERM -> SIGKILL with 700/700/500 ms waits, +// so a cooperative-refusing tree costs ~1.9s before the kill lands. The point of this +// assertion is that teardown is BOUNDED rather than hanging or waiting out a request +// timeout, so allow real headroom for spawn plus process-tree enumeration — which is +// markedly slower on Windows, where a 2.5s bound flaked at 3.8s on CI. +const ESCALATION_BUDGET_MS = 700 + 700 + 500; +const TEARDOWN_BUDGET_MS = process.platform === 'win32' ? ESCALATION_BUDGET_MS * 4 : ESCALATION_BUDGET_MS * 2; + describe('stdio runtime close', () => { it('reaps a SIGTERM-resistant process tree with inherited stdio in bounded time', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-stdio-close-')); @@ -52,7 +60,7 @@ describe('stdio runtime close', () => { definition, }); - expect(Date.now() - started).toBeLessThan(2_500); + expect(Date.now() - started).toBeLessThan(TEARDOWN_BUDGET_MS); await expectProcessExit(rootPid); await expectProcessExit(descendantPid); cleanupPids.delete(rootPid); From 26fb1827685d4ce49fe6ee7d17ff2fef30c20e5f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 23:54:19 -0700 Subject: [PATCH 3/3] test: scale e2e CLI budgets for slow Windows runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite spawns the real CLI repeatedly; it runs ~25s locally and ~100s on a Windows runner, so a flat 30s per-test budget timed out there. Scale the long budgets by platform instead of tuning them one flake at a time — they exist to catch hangs, not to measure machine speed. Co-Authored-By: Claude Fable 5 --- tests/e2e-fixture-servers.test.ts | 267 ++++++++++++++++-------------- 1 file changed, 143 insertions(+), 124 deletions(-) diff --git a/tests/e2e-fixture-servers.test.ts b/tests/e2e-fixture-servers.test.ts index 5e8d1589..373926b5 100644 --- a/tests/e2e-fixture-servers.test.ts +++ b/tests/e2e-fixture-servers.test.ts @@ -15,6 +15,13 @@ import { waitForChildExit } from '../src/process-utils.js'; import { createRuntime } from '../src/runtime.js'; import { makeShortTempDir } from './fixtures/test-helpers.js'; +// These tests spawn the real CLI repeatedly, and Windows pays a far higher +// process-startup cost: the same suite runs ~25s locally and ~100s on a Windows +// runner. Scale the per-test budgets rather than tuning them one flake at a +// time — they exist to catch hangs, not to measure machine speed. +const CI_SLOWDOWN = process.platform === 'win32' ? 3 : 1; +const budget = (ms: number): number => ms * CI_SLOWDOWN; + const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); const CLI_ENTRY = path.join(REPO_ROOT, 'dist', 'cli.js'); const LEGACY_SERVER = path.join(REPO_ROOT, 'tests', 'servers', 'legacy', 'server.ts'); @@ -46,7 +53,7 @@ beforeAll(async () => { startHttpFixture('legacy', LEGACY_SERVER), startHttpFixture('modern', MODERN_SERVER), ]); -}, 20_000); +}, budget(20_000)); afterAll(async () => { await Promise.allSettled([...spawnedChildren].map((child) => stopChild(child))); @@ -54,64 +61,68 @@ afterAll(async () => { describe.each(fixtureKinds)('%s fixture through the real CLI', (fixture) => { describe.each(transports)('%s', (transport) => { - it('lists, calls, fails, reports structured output, completes progress work, and reads resources', async () => { - await withConfig({ fixture: configFor(fixture, transport) }, async (configPath, env) => { - const listed = await runCli(['list', 'fixture', '--json', '--verbose', '--no-oauth'], configPath, env); - expect(listed.exitCode, listed.stderr).toBe(0); - const listPayload = parseJson<{ - tools: Array<{ name: string }>; - protocolVersion?: string; - era?: string; - }>(listed.stdout); - expect(listPayload.tools.map((tool) => tool.name)).toContain('echo'); - if (fixture === 'legacy') { - expect(listPayload.tools.length).toBeGreaterThan(60); - expect(listPayload.tools.at(-1)?.name).toBe('many_tools_60'); - } else { - expect(listPayload.protocolVersion).toBe('2026-07-28'); - expect(listPayload.era).toBe('modern'); - } - - const echo = await runCli(['call', 'fixture.echo', 'text=fixture-echo', '--output', 'json'], configPath, env); - expect(echo.exitCode, echo.stderr).toBe(0); - expect(echo.stdout).toContain('fixture-echo'); - - const add = await runCli(['call', 'fixture.add', 'a=19', 'b=23', '--output', 'json'], configPath, env); - expect(add.exitCode, add.stderr).toBe(0); - expect(parseJson<{ result: number }>(add.stdout)).toEqual({ result: 42 }); - - const failed = await runCli(['call', 'fixture.fail', '--output', 'json'], configPath, env); - expect(failed.exitCode).not.toBe(0); - expect(`${failed.stdout}\n${failed.stderr}`).toContain(`${fixture} requested failure`); - - const longTask = await runCli(['call', 'fixture.long_task', 'steps=3', '--output', 'text'], configPath, env); - expect(longTask.exitCode, longTask.stderr).toBe(0); - expect(longTask.stdout).toContain(`${fixture} long task completed 3 steps`); - - const resources = await runCli(['resource', 'fixture', '--json'], configPath, env); - expect(resources.exitCode, resources.stderr).toBe(0); - const resourcePayload = parseJson<{ resources: Array<{ uri: string }> }>(resources.stdout); - expect(resourcePayload.resources.map((resource) => resource.uri)).toContain(`fixture://${fixture}/welcome`); - - const welcome = await runCli( - ['resource', 'fixture', `fixture://${fixture}/welcome`, '--output', 'text'], - configPath, - env - ); - expect(welcome.exitCode, welcome.stderr).toBe(0); - expect(welcome.stdout).toContain(`hello from the ${fixture} fixture`); - - if (fixture === 'legacy') { - const binary = await runCli( - ['resource', 'fixture', 'fixture://legacy/binary', '--output', 'json'], + it( + 'lists, calls, fails, reports structured output, completes progress work, and reads resources', + async () => { + await withConfig({ fixture: configFor(fixture, transport) }, async (configPath, env) => { + const listed = await runCli(['list', 'fixture', '--json', '--verbose', '--no-oauth'], configPath, env); + expect(listed.exitCode, listed.stderr).toBe(0); + const listPayload = parseJson<{ + tools: Array<{ name: string }>; + protocolVersion?: string; + era?: string; + }>(listed.stdout); + expect(listPayload.tools.map((tool) => tool.name)).toContain('echo'); + if (fixture === 'legacy') { + expect(listPayload.tools.length).toBeGreaterThan(60); + expect(listPayload.tools.at(-1)?.name).toBe('many_tools_60'); + } else { + expect(listPayload.protocolVersion).toBe('2026-07-28'); + expect(listPayload.era).toBe('modern'); + } + + const echo = await runCli(['call', 'fixture.echo', 'text=fixture-echo', '--output', 'json'], configPath, env); + expect(echo.exitCode, echo.stderr).toBe(0); + expect(echo.stdout).toContain('fixture-echo'); + + const add = await runCli(['call', 'fixture.add', 'a=19', 'b=23', '--output', 'json'], configPath, env); + expect(add.exitCode, add.stderr).toBe(0); + expect(parseJson<{ result: number }>(add.stdout)).toEqual({ result: 42 }); + + const failed = await runCli(['call', 'fixture.fail', '--output', 'json'], configPath, env); + expect(failed.exitCode).not.toBe(0); + expect(`${failed.stdout}\n${failed.stderr}`).toContain(`${fixture} requested failure`); + + const longTask = await runCli(['call', 'fixture.long_task', 'steps=3', '--output', 'text'], configPath, env); + expect(longTask.exitCode, longTask.stderr).toBe(0); + expect(longTask.stdout).toContain(`${fixture} long task completed 3 steps`); + + const resources = await runCli(['resource', 'fixture', '--json'], configPath, env); + expect(resources.exitCode, resources.stderr).toBe(0); + const resourcePayload = parseJson<{ resources: Array<{ uri: string }> }>(resources.stdout); + expect(resourcePayload.resources.map((resource) => resource.uri)).toContain(`fixture://${fixture}/welcome`); + + const welcome = await runCli( + ['resource', 'fixture', `fixture://${fixture}/welcome`, '--output', 'text'], configPath, env ); - expect(binary.exitCode, binary.stderr).toBe(0); - expect(binary.stdout).toContain('AAEC/f7/'); - } - }); - }, 30_000); + expect(welcome.exitCode, welcome.stderr).toBe(0); + expect(welcome.stdout).toContain(`hello from the ${fixture} fixture`); + + if (fixture === 'legacy') { + const binary = await runCli( + ['resource', 'fixture', 'fixture://legacy/binary', '--output', 'json'], + configPath, + env + ); + expect(binary.exitCode, binary.stderr).toBe(0); + expect(binary.stdout).toContain('AAEC/f7/'); + } + }); + }, + budget(30_000) + ); }); }); @@ -172,81 +183,89 @@ describe.each(transports)('modern MRTR and identity over %s', (transport) => { }); }); -it('streams modern tool-list changes and exposes cache metadata', async () => { - const client = new ModernClient( - { name: 'fixture-modern-subscription-client', version: '1.0.0' }, - { versionNegotiation: { mode: { pin: '2026-07-28' } } } - ); - let toolListChanges = 0; - client.setNotificationHandler('notifications/tools/list_changed', () => { - toolListChanges += 1; - }); - - try { - await client.connect(new ModernHttpTransport(new URL(modernHttp.url))); - const initial = await client.listTools(undefined, { cacheMode: 'refresh' }); - expect(initial).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' }); - const initiallyEnabled = initial.tools.some((tool) => tool.name === 'runtime_tool'); - const subscription = await client.listen({ toolsListChanged: true }); - expect(subscription.honoredFilter).toEqual({ toolsListChanged: true }); +it( + 'streams modern tool-list changes and exposes cache metadata', + async () => { + const client = new ModernClient( + { name: 'fixture-modern-subscription-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); + let toolListChanges = 0; + client.setNotificationHandler('notifications/tools/list_changed', () => { + toolListChanges += 1; + }); try { - await client.callTool({ name: 'toggle_tool', arguments: {} }); - await expect.poll(() => toolListChanges, { timeout: 2_000 }).toBeGreaterThan(0); - const refreshed = await client.listTools(undefined, { cacheMode: 'refresh' }); - expect(refreshed).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' }); - expect(refreshed.tools.some((tool) => tool.name === 'runtime_tool')).toBe(!initiallyEnabled); - } finally { - await subscription.close(); - } - } finally { - await client.close(); - } -}, 20_000); + await client.connect(new ModernHttpTransport(new URL(modernHttp.url))); + const initial = await client.listTools(undefined, { cacheMode: 'refresh' }); + expect(initial).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' }); + const initiallyEnabled = initial.tools.some((tool) => tool.name === 'runtime_tool'); + const subscription = await client.listen({ toolsListChanged: true }); + expect(subscription.honoredFilter).toEqual({ toolsListChanged: true }); -it('bridges both fixtures to pinned modern and legacy HTTP clients through mcporter serve', async () => { - await withConfig( - { - legacy: { ...configFor('legacy', 'http'), lifecycle: 'keep-alive' }, - modern: { ...configFor('modern', 'http'), lifecycle: 'keep-alive' }, - }, - async (configPath, env, tempDir) => { - const daemon = await runCli(['daemon', 'start', '--log'], configPath, env); - const daemonLogs = daemon.exitCode === 0 ? '' : await readDaemonLogs(path.join(tempDir, 'daemon')); - expect(daemon.exitCode, `${daemon.stdout}\n${daemon.stderr}\n${daemonLogs}`).toBe(0); - const bridge = await startBridge(configPath, env); - const modernClient = new ModernClient( - { name: 'fixture-modern-bridge-client', version: '1.0.0' }, - { versionNegotiation: { mode: { pin: '2026-07-28' } } } - ); - const legacyClient = new LegacyClient({ name: 'fixture-legacy-bridge-client', version: '1.0.0' }); try { - await modernClient.connect(new ModernHttpTransport(new URL(bridge.url))); - expect(modernClient.getProtocolEra()).toBe('modern'); - const modernTools = await modernClient.listTools(); - expect(modernTools.tools.map((tool) => tool.name)).toEqual( - expect.arrayContaining(['legacy__echo', 'modern__echo']) - ); - await expect( - modernClient.callTool({ name: 'modern__echo', arguments: { text: 'modern bridge' } }) - ).resolves.toMatchObject({ content: [{ type: 'text', text: 'modern bridge' }] }); - - await legacyClient.connect(new LegacyHttpTransport(new URL(bridge.url))); - const legacyTools = await legacyClient.listTools(); - expect(legacyTools.tools.map((tool) => tool.name)).toEqual( - expect.arrayContaining(['legacy__echo', 'modern__echo']) - ); - await expect( - legacyClient.callTool({ name: 'legacy__echo', arguments: { text: 'legacy bridge' } }) - ).resolves.toMatchObject({ content: [{ type: 'text', text: 'legacy bridge' }] }); + await client.callTool({ name: 'toggle_tool', arguments: {} }); + await expect.poll(() => toolListChanges, { timeout: 2_000 }).toBeGreaterThan(0); + const refreshed = await client.listTools(undefined, { cacheMode: 'refresh' }); + expect(refreshed).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' }); + expect(refreshed.tools.some((tool) => tool.name === 'runtime_tool')).toBe(!initiallyEnabled); } finally { - await Promise.allSettled([modernClient.close(), legacyClient.close()]); - await stopChild(bridge.child); - await runCli(['daemon', 'stop'], configPath, { ...env, MCPORTER_DAEMON_DIR: path.join(tempDir, 'daemon') }); + await subscription.close(); } + } finally { + await client.close(); } - ); -}, 40_000); + }, + budget(20_000) +); + +it( + 'bridges both fixtures to pinned modern and legacy HTTP clients through mcporter serve', + async () => { + await withConfig( + { + legacy: { ...configFor('legacy', 'http'), lifecycle: 'keep-alive' }, + modern: { ...configFor('modern', 'http'), lifecycle: 'keep-alive' }, + }, + async (configPath, env, tempDir) => { + const daemon = await runCli(['daemon', 'start', '--log'], configPath, env); + const daemonLogs = daemon.exitCode === 0 ? '' : await readDaemonLogs(path.join(tempDir, 'daemon')); + expect(daemon.exitCode, `${daemon.stdout}\n${daemon.stderr}\n${daemonLogs}`).toBe(0); + const bridge = await startBridge(configPath, env); + const modernClient = new ModernClient( + { name: 'fixture-modern-bridge-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); + const legacyClient = new LegacyClient({ name: 'fixture-legacy-bridge-client', version: '1.0.0' }); + try { + await modernClient.connect(new ModernHttpTransport(new URL(bridge.url))); + expect(modernClient.getProtocolEra()).toBe('modern'); + const modernTools = await modernClient.listTools(); + expect(modernTools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(['legacy__echo', 'modern__echo']) + ); + await expect( + modernClient.callTool({ name: 'modern__echo', arguments: { text: 'modern bridge' } }) + ).resolves.toMatchObject({ content: [{ type: 'text', text: 'modern bridge' }] }); + + await legacyClient.connect(new LegacyHttpTransport(new URL(bridge.url))); + const legacyTools = await legacyClient.listTools(); + expect(legacyTools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(['legacy__echo', 'modern__echo']) + ); + await expect( + legacyClient.callTool({ name: 'legacy__echo', arguments: { text: 'legacy bridge' } }) + ).resolves.toMatchObject({ content: [{ type: 'text', text: 'legacy bridge' }] }); + } finally { + await Promise.allSettled([modernClient.close(), legacyClient.close()]); + await stopChild(bridge.child); + await runCli(['daemon', 'stop'], configPath, { ...env, MCPORTER_DAEMON_DIR: path.join(tempDir, 'daemon') }); + } + } + ); + }, + budget(40_000) +); describe('fixture child lifecycle', () => { it('kills a fixture child when readiness times out', async () => {