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.`); 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 () => { 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);