From 951c397eadf7ddcbbcff6c3ec989d125ab6eccb7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 17:44:33 -0700 Subject: [PATCH 1/2] govern consumer contracts and CI performance --- .github/workflows/ci-performance.yml | 43 ++ .github/workflows/ci.yml | 2 +- docs/packages/sdk/verifast.md | 13 +- docs/reference/ci-performance.md | 51 ++ docs/reference/package-api-migrations.md | 6 +- docs/reference/stack-facts.md | 64 +- governance/ci-performance-baseline.json | 614 ++++++++++++++++++++ governance/package-release-notes.json | 4 +- governance/repository-health/baselines.json | 2 +- governance/repository-health/projects.json | 85 ++- packages/verifast/README.md | 10 +- packages/verifast/browser-budget.json | 36 ++ packages/verifast/browser/fallback.html | 11 + packages/verifast/browser/fallback.ts | 18 + packages/verifast/browser/main.ts | 2 +- packages/verifast/browser/test.mjs | 237 +++++++- packages/verifast/browser/umd-main.js | 23 + packages/verifast/browser/umd.html | 31 +- packages/verifast/package.json | 6 +- packages/verifast/rspack.config.js | 1 + packages/verifast/scripts/copy-umd.mjs | 10 +- packages/verifast/tsconfig.build.json | 1 + scripts/check-browser-package.mjs | 50 +- scripts/check-browser-package.test.mjs | 28 + scripts/ci-performance.mjs | 434 ++++++++++++++ scripts/ci-performance.test.mjs | 121 ++++ scripts/generate-stack-facts.mjs | 12 +- scripts/repository-health.mjs | 185 +++++- scripts/repository-health.test.mjs | 67 +++ 29 files changed, 2051 insertions(+), 116 deletions(-) create mode 100644 .github/workflows/ci-performance.yml create mode 100644 docs/reference/ci-performance.md create mode 100644 governance/ci-performance-baseline.json create mode 100644 packages/verifast/browser-budget.json create mode 100644 packages/verifast/browser/fallback.html create mode 100644 packages/verifast/browser/fallback.ts create mode 100644 packages/verifast/browser/umd-main.js create mode 100644 scripts/ci-performance.mjs create mode 100644 scripts/ci-performance.test.mjs diff --git a/.github/workflows/ci-performance.yml b/.github/workflows/ci-performance.yml new file mode 100644 index 000000000..3844da8e4 --- /dev/null +++ b/.github/workflows/ci-performance.yml @@ -0,0 +1,43 @@ +name: CI performance trend + +on: + schedule: + - cron: '17 8 * * 1' + workflow_dispatch: + +permissions: {} + +jobs: + trend: + name: CI performance / 20-run trend and budget + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + + - name: Compare the latest representative runs with the governed baseline + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: >- + node scripts/ci-performance.mjs + --collect + --baseline governance/ci-performance-baseline.json + --output ci-performance-report.json + + - name: Preserve the per-job and per-step trend evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: ci-performance-report + path: ci-performance-report.json + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0171c3968..33182be54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,7 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" CHANGED=$(git diff --name-only "${BASE_SHA}"...HEAD) - if echo "$CHANGED" | grep -Eq '^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.base.json|codecov.yml|sonar-project.properties|\.sonarcloud\.properties|\.github/workflows/(ci|property-tests|mutation-tests|release)\.(yml|yaml)|governance/(npm-package-supply-chain\.json|repository-health|test-quality|mutation-testing)/|scripts/(check-browser-package|check-package-artifact|check-wallet-toolbox-platform|package-release-artifacts|repository-health|run-ci-tests|run-prebuilt-package-script|normalize-lcov-paths|run-governed-test|sonar-pr-gate|test-governance|mutation-testing|typescript-toolchain|sync-service-(rate-limit|edge)-policy|lib/workspace-packages)\.(mjs|test\.mjs)$)'; then + if echo "$CHANGED" | grep -Eq '^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.base.json|codecov.yml|sonar-project.properties|\.sonarcloud\.properties|\.github/workflows/(ci|ci-performance|property-tests|mutation-tests|release)\.(yml|yaml)|governance/(npm-package-supply-chain\.json|repository-health|test-quality|mutation-testing)/|scripts/(check-browser-package|check-package-artifact|check-wallet-toolbox-platform|ci-performance|package-release-artifacts|repository-health|run-ci-tests|run-prebuilt-package-script|normalize-lcov-paths|run-governed-test|sonar-pr-gate|test-governance|mutation-testing|typescript-toolchain|sync-service-(rate-limit|edge)-policy|lib/workspace-packages)\.(mjs|test\.mjs)$)'; then FILTER="" else FILTER="...[${BASE_SHA}]" diff --git a/docs/packages/sdk/verifast.md b/docs/packages/sdk/verifast.md index 10614ffa7..505f0e664 100644 --- a/docs/packages/sdk/verifast.md +++ b/docs/packages/sdk/verifast.md @@ -3,9 +3,9 @@ id: pkg-verifast title: '@bsv/verifast' kind: package domain: sdk -version: '0.3.1' -last_updated: '2026-07-28' -last_verified: '2026-07-28' +version: '0.3.2' +last_updated: '2026-07-30' +last_verified: '2026-07-30' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/verifast' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast' @@ -46,8 +46,11 @@ warms and routes eligible verification work to WASM afterward. Use explicit consensus or policy context when establishing transaction validity. Batch and worker APIs are bounded and can be preloaded for latency-sensitive workloads. -Build and package checks exercise real WASM verification in Node, browser -bundlers, workers, CommonJS, and the classic browser payload. See the +Build and package checks install exact tarballs and exercise real WASM +verification in Node, browser bundlers, workers, CommonJS, strict-CSP browser +pages, streaming fallback, and the classic browser payload. Raw, gzip, and +Brotli budgets guard both browser bundles and the complete UMD loaders-plus-WASM +composition. See the [package README](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast#readme) for routing, network, batch, resource, and UMD details. diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md new file mode 100644 index 000000000..0856b9b60 --- /dev/null +++ b/docs/reference/ci-performance.md @@ -0,0 +1,51 @@ +--- +id: ci-performance +title: 'CI Performance Governance' +kind: reference +version: '1.0.0' +last_updated: '2026-07-30' +last_verified: '2026-07-30' +review_cadence_days: 30 +status: stable +tags: [reference, ci, performance, github-actions] +--- + +# CI Performance Governance + +The weekly `CI performance trend` workflow classifies successful pull-request +CI runs as full-scope (at least 50 executed, non-skipped jobs) or targeted, +retains the latest 20 of each class, and compares median and p95 end-to-end duration with +`governance/ci-performance-baseline.json`. A material regression fails the +workflow and requires timing evidence before its budget or baseline changes. + +The uploaded JSON report retains run, job, and step duration; queue time; +prepare-job duration; artifact upload/download duration; and variance. This +separates targeted feedback from the complete merge gate so a changing PR mix +cannot make the trend appear faster or slower by accident. + +GitHub's Actions REST API does not expose hosted-runner CPU utilization, memory +utilization, or action-internal cache-hit results. Those fields remain an +explicit instrumentation gap in the baseline rather than being estimated from +wall-clock time. Add deliberate in-run instrumentation before claiming those +metrics. + +To refresh the evidence without changing the baseline: + +```bash +GITHUB_TOKEN=... node scripts/ci-performance.mjs \ + --collect \ + --baseline governance/ci-performance-baseline.json \ + --output ci-performance-report.json +``` + +Changing the committed baseline requires a reviewed PR: + +```bash +GITHUB_TOKEN=... node scripts/ci-performance.mjs \ + --collect \ + --write-baseline governance/ci-performance-baseline.json +``` + +Review the 40 exact run links, classification threshold, sample summaries, +workflow or runner changes, and the stated median/p95 budget. Never loosen a +budget solely to make a red trend green. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 4be2683f8..270840a4c 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -48,7 +48,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/simple` | `0.4.1` | `0.4.6` | patch | [API and usage](../packages/helpers/simple.md) | No consumer migration is required; the browser and server entry points remain compatible. | | `@bsv/templates` | `1.9.1` | `1.9.3` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.2` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | -| `@bsv/verifast` | `0.3.0` | `0.3.1` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; valid verification results and worker protocols are unchanged. | +| `@bsv/verifast` | `0.3.0` | `0.3.2` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | | `@bsv/wallet-helper` | `0.1.1` | `0.1.4` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | | `@bsv/wallet-relay` | `0.2.2` | `0.3.0` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. | | `@bsv/wallet-toolbox` | `2.4.4` | `2.4.16` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required; persisted schemas, provider behavior, transaction construction, monitor behavior, and the 2.x wallet and storage interfaces remain compatible. | @@ -399,8 +399,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/sdk/verifast.md](../packages/sdk/verifast.md) - Source: [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) -- Release note: Uses precise worker result type errors and refreshes the compatible browser verification test client. -- Migration: No consumer migration is required; valid verification results and worker protocols are unchanged. +- Release note: Adds exact-tarball browser, WASM, worker, strict-CSP, streaming-fallback, UMD source-map, and raw/gzip/Brotli bundle contracts. +- Migration: No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 696041a22..a52caf96f 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -35,38 +35,38 @@ The release graph currently contains **30 public packages**. Versions below are source-manifest versions; registry publication is a separate, explicitly authorized release action. -| Area | Package | Source version | Profile | Runtime targets | Node engine | Source | -| --- | --- | --- | --- | --- | --- | --- | -| helpers | `@bsv/amountinator` | `2.1.2` | node-library | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | -| helpers | `@bsv/did` | `0.2.2` | browser-library | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | -| helpers | `@bsv/did-client` | `1.2.1` | browser-library | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | -| helpers | `@bsv/fund-wallet` | `1.4.1` | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | -| helpers | `@bsv/simple` | `0.4.6` | browser-library | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | -| helpers | `@bsv/templates` | `1.9.3` | browser-library | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | -| helpers | `@bsv/wallet-helper` | `0.1.4` | node-library | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | -| helpers | `create-bsv-app` | `1.0.2` | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | -| messaging | `@bsv/authsocket` | `2.1.2` | node-library | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | -| messaging | `@bsv/authsocket-client` | `2.1.1` | browser-library | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | -| messaging | `@bsv/message-box-client` | `2.2.4` | browser-library | browser, node | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | -| messaging | `@bsv/paymail` | `2.4.3` | browser-library | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | -| middleware | `@bsv/402-pay` | `0.2.2` | browser-library | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | -| middleware | `@bsv/auth` | `0.1.1` | node-library | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | -| middleware | `@bsv/auth-express-middleware` | `2.1.3` | node-library | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | -| middleware | `@bsv/payment-express-middleware` | `2.1.2` | node-library | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | -| network | `@bsv/teranode-listener` | `1.1.2` | node-library | node | `>=22` | [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) | -| overlays | `@bsv/gasp` | `1.3.3` | browser-library | browser, node | `>=22` | [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) | -| overlays | `@bsv/overlay` | `2.2.5` | node-library | node | `>=22` | [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) | -| overlays | `@bsv/overlay-discovery-services` | `2.1.4` | node-library | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | -| overlays | `@bsv/overlay-express` | `2.4.7` | node-library | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | -| overlays | `@bsv/overlay-topics` | `1.6.6` | node-library | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | -| sdk | `@bsv/sdk` | `2.2.12` | browser-library | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | -| sdk | `@bsv/verifast` | `0.3.1` | wasm-library | browser, node, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | -| wallet | `@bsv/btms` | `1.1.2` | node-library | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | -| wallet | `@bsv/btms-permission-module` | `1.1.1` | node-library | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | -| wallet | `@bsv/wallet-relay` | `0.3.0` | cli-library | node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | -| wallet | `@bsv/wallet-toolbox` | `2.4.16` | node-library | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | -| wallet | `@bsv/wallet-toolbox-client` | `2.4.16` | browser-library | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | -| wallet | `@bsv/wallet-toolbox-mobile` | `2.4.16` | react-native-library | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | +| Area | Package | Source version | Project profile | Consumer profiles | Runtime targets | Node engine | Source | +| --- | --- | --- | --- | --- | --- | --- | --- | +| helpers | `@bsv/amountinator` | `2.1.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | +| helpers | `@bsv/did` | `0.2.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | +| helpers | `@bsv/did-client` | `1.2.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | +| helpers | `@bsv/fund-wallet` | `1.4.1` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | +| helpers | `@bsv/simple` | `0.4.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | +| helpers | `@bsv/templates` | `1.9.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | +| helpers | `@bsv/wallet-helper` | `0.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | +| helpers | `create-bsv-app` | `1.0.2` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | +| messaging | `@bsv/authsocket` | `2.1.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | +| messaging | `@bsv/authsocket-client` | `2.1.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | +| messaging | `@bsv/message-box-client` | `2.2.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | +| messaging | `@bsv/paymail` | `2.4.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | +| middleware | `@bsv/402-pay` | `0.2.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | +| middleware | `@bsv/auth` | `0.1.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | +| middleware | `@bsv/auth-express-middleware` | `2.1.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | +| middleware | `@bsv/payment-express-middleware` | `2.1.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | +| network | `@bsv/teranode-listener` | `1.1.2` | node-library | node-esm | node | `>=22` | [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) | +| overlays | `@bsv/gasp` | `1.3.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) | +| overlays | `@bsv/overlay` | `2.2.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) | +| overlays | `@bsv/overlay-discovery-services` | `2.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | +| overlays | `@bsv/overlay-express` | `2.4.7` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | +| overlays | `@bsv/overlay-topics` | `1.6.6` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | +| sdk | `@bsv/sdk` | `2.2.12` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | +| sdk | `@bsv/verifast` | `0.3.2` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | +| wallet | `@bsv/btms` | `1.1.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | +| wallet | `@bsv/btms-permission-module` | `1.1.1` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | +| wallet | `@bsv/wallet-relay` | `0.3.0` | cli-library | browser-bundler, browser-esm, cli, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | +| wallet | `@bsv/wallet-toolbox` | `2.4.16` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | +| wallet | `@bsv/wallet-toolbox-client` | `2.4.16` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | +| wallet | `@bsv/wallet-toolbox-mobile` | `2.4.16` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | ## Standalone infrastructure manifests diff --git a/governance/ci-performance-baseline.json b/governance/ci-performance-baseline.json new file mode 100644 index 000000000..4f5569008 --- /dev/null +++ b/governance/ci-performance-baseline.json @@ -0,0 +1,614 @@ +{ + "schemaVersion": 1, + "recordedAt": "2026-07-30T00:35:48.389Z", + "source": { + "repository": "bsv-blockchain/ts-stack", + "workflow": "ci.yml", + "event": "pull_request", + "conclusion": "success" + }, + "classification": { + "fullScopeMinimumJobs": 50, + "sampleSizePerClass": 20 + }, + "regressionBudget": { + "fullScope": { + "medianPercent": 15, + "p95Percent": 20 + }, + "targeted": { + "medianPercent": 20, + "p95Percent": 25 + } + }, + "observability": { + "captured": [ + "run duration and variance", + "per-job and per-step duration", + "job queue time", + "artifact upload/download step duration" + ], + "unavailableFromActionsApi": [ + "hosted-runner CPU utilization", + "hosted-runner memory utilization", + "action-internal cache hit rate" + ], + "note": "Unavailable metrics require explicit in-run instrumentation; they are not inferred from the GitHub Actions REST API." + }, + "reference": { + "fullScope": { + "summary": { + "runCount": 20, + "durationSeconds": { + "samples": 20, + "minimum": 527, + "median": 570, + "p95": 892, + "maximum": 979, + "standardDeviation": 115 + }, + "queueSeconds": { + "samples": 20, + "minimum": 2, + "median": 3, + "p95": 35, + "maximum": 42, + "standardDeviation": 14 + }, + "prepareSeconds": { + "samples": 20, + "minimum": 181, + "median": 218, + "p95": 233, + "maximum": 238, + "standardDeviation": 13 + }, + "artifactTransferSeconds": { + "samples": 20, + "minimum": 58, + "median": 68, + "p95": 78, + "maximum": 86, + "standardDeviation": 7 + }, + "jobCount": { + "samples": 20, + "minimum": 50, + "median": 51, + "p95": 57, + "maximum": 58, + "standardDeviation": 3 + } + }, + "runs": [ + { + "id": 30501070016, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30501070016", + "headSha": "a3107812dd125e0cb6960bdb690d1f68388e035c", + "createdAt": "2026-07-29T23:55:41Z", + "durationSeconds": 527, + "queueSeconds": 2, + "prepareSeconds": 181, + "artifactTransferSeconds": 76, + "jobCount": 58, + "declaredJobCount": 59 + }, + { + "id": 30497961865, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30497961865", + "headSha": "bb4bbdc09ce094c19b65aef28beb5b95db988a85", + "createdAt": "2026-07-29T22:58:19Z", + "durationSeconds": 892, + "queueSeconds": 3, + "prepareSeconds": 220, + "artifactTransferSeconds": 78, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30496301166, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30496301166", + "headSha": "ac65c140583e20806e682d36736732e95ed14170", + "createdAt": "2026-07-29T22:29:14Z", + "durationSeconds": 979, + "queueSeconds": 2, + "prepareSeconds": 238, + "artifactTransferSeconds": 72, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30495602908, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30495602908", + "headSha": "2edfe20bae6ce2227b467d6823958067ba74e4c2", + "createdAt": "2026-07-29T22:17:32Z", + "durationSeconds": 612, + "queueSeconds": 42, + "prepareSeconds": 227, + "artifactTransferSeconds": 76, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30428000587, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30428000587", + "headSha": "b0c2e15db572d2db50852c820d4dd7e0786486fe", + "createdAt": "2026-07-29T06:23:16Z", + "durationSeconds": 577, + "queueSeconds": 2, + "prepareSeconds": 206, + "artifactTransferSeconds": 67, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30419109237, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30419109237", + "headSha": "98607e32ce5d048c30a3173a57b570fbdb49050e", + "createdAt": "2026-07-29T03:16:00Z", + "durationSeconds": 554, + "queueSeconds": 2, + "prepareSeconds": 230, + "artifactTransferSeconds": 68, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30416684054, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30416684054", + "headSha": "b43092d5e376980aedc547cf85c8fa1cc77c69c4", + "createdAt": "2026-07-29T02:23:07Z", + "durationSeconds": 545, + "queueSeconds": 2, + "prepareSeconds": 216, + "artifactTransferSeconds": 66, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30409489114, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30409489114", + "headSha": "3a6ce91e6ae5973bb3873736d251c93240b269de", + "createdAt": "2026-07-28T23:54:48Z", + "durationSeconds": 570, + "queueSeconds": 2, + "prepareSeconds": 224, + "artifactTransferSeconds": 69, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30408074671, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30408074671", + "headSha": "6ee194a44390dc97814fd2bdfe360053a85bc558", + "createdAt": "2026-07-28T23:28:13Z", + "durationSeconds": 585, + "queueSeconds": 30, + "prepareSeconds": 205, + "artifactTransferSeconds": 76, + "jobCount": 50, + "declaredJobCount": 50 + }, + { + "id": 30405101676, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30405101676", + "headSha": "b899b54ef49d5017cf24d5652bf139ffd2758dba", + "createdAt": "2026-07-28T22:35:30Z", + "durationSeconds": 565, + "queueSeconds": 2, + "prepareSeconds": 231, + "artifactTransferSeconds": 69, + "jobCount": 51, + "declaredJobCount": 51 + }, + { + "id": 30404076395, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30404076395", + "headSha": "658a8a893585ca6357858428d8d02a4b9c4b6fce", + "createdAt": "2026-07-28T22:18:43Z", + "durationSeconds": 562, + "queueSeconds": 3, + "prepareSeconds": 224, + "artifactTransferSeconds": 63, + "jobCount": 51, + "declaredJobCount": 51 + }, + { + "id": 30402930875, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30402930875", + "headSha": "12cda5828932188d8ad5994777017342872687ef", + "createdAt": "2026-07-28T22:00:36Z", + "durationSeconds": 583, + "queueSeconds": 31, + "prepareSeconds": 214, + "artifactTransferSeconds": 68, + "jobCount": 51, + "declaredJobCount": 51 + }, + { + "id": 30398700176, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30398700176", + "headSha": "7b084d2333f32a1ed22f42da8560235fa8446c94", + "createdAt": "2026-07-28T20:58:54Z", + "durationSeconds": 600, + "queueSeconds": 3, + "prepareSeconds": 233, + "artifactTransferSeconds": 63, + "jobCount": 57, + "declaredJobCount": 57 + }, + { + "id": 30388511059, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30388511059", + "headSha": "71c4546ea139dfdb6986ed9a2e2ecf2d01196404", + "createdAt": "2026-07-28T18:40:37Z", + "durationSeconds": 627, + "queueSeconds": 31, + "prepareSeconds": 214, + "artifactTransferSeconds": 59, + "jobCount": 57, + "declaredJobCount": 57 + }, + { + "id": 30382380922, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30382380922", + "headSha": "e7058e4821866753539dcf88d3d114b4b71fda38", + "createdAt": "2026-07-28T17:19:43Z", + "durationSeconds": 665, + "queueSeconds": 3, + "prepareSeconds": 208, + "artifactTransferSeconds": 86, + "jobCount": 55, + "declaredJobCount": 55 + }, + { + "id": 30381225149, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30381225149", + "headSha": "2cc8d719a6a0f0af27507203103e458fe75eb5f0", + "createdAt": "2026-07-28T17:04:31Z", + "durationSeconds": 727, + "queueSeconds": 24, + "prepareSeconds": 215, + "artifactTransferSeconds": 69, + "jobCount": 55, + "declaredJobCount": 55 + }, + { + "id": 30332441684, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30332441684", + "headSha": "a048d6d4a45745852291706db98d69e0f404a1d9", + "createdAt": "2026-07-28T05:42:25Z", + "durationSeconds": 548, + "queueSeconds": 5, + "prepareSeconds": 218, + "artifactTransferSeconds": 65, + "jobCount": 56, + "declaredJobCount": 56 + }, + { + "id": 30329465728, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30329465728", + "headSha": "de8c1576d022912eb618528307f6dd32168d4c64", + "createdAt": "2026-07-28T04:41:56Z", + "durationSeconds": 545, + "queueSeconds": 3, + "prepareSeconds": 230, + "artifactTransferSeconds": 58, + "jobCount": 54, + "declaredJobCount": 54 + }, + { + "id": 30328159156, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30328159156", + "headSha": "d846f82b066b0315c68bcbe4e66a040236aa964d", + "createdAt": "2026-07-28T04:13:46Z", + "durationSeconds": 570, + "queueSeconds": 35, + "prepareSeconds": 207, + "artifactTransferSeconds": 58, + "jobCount": 54, + "declaredJobCount": 54 + }, + { + "id": 30305296283, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30305296283", + "headSha": "443011429507eb4d2306f210963f42d7dcb4b9c1", + "createdAt": "2026-07-27T21:04:24Z", + "durationSeconds": 560, + "queueSeconds": 3, + "prepareSeconds": 219, + "artifactTransferSeconds": 72, + "jobCount": 56, + "declaredJobCount": 56 + } + ] + }, + "targeted": { + "summary": { + "runCount": 20, + "durationSeconds": { + "samples": 20, + "minimum": 177, + "median": 425, + "p95": 557, + "maximum": 561, + "standardDeviation": 109 + }, + "queueSeconds": { + "samples": 20, + "minimum": 2, + "median": 2, + "p95": 44, + "maximum": 61, + "standardDeviation": 17 + }, + "prepareSeconds": { + "samples": 20, + "minimum": 105, + "median": 207, + "p95": 231, + "maximum": 239, + "standardDeviation": 36 + }, + "artifactTransferSeconds": { + "samples": 20, + "minimum": 7, + "median": 32, + "p95": 72, + "maximum": 84, + "standardDeviation": 23 + }, + "jobCount": { + "samples": 20, + "minimum": 15, + "median": 30, + "p95": 49, + "maximum": 49, + "standardDeviation": 13 + } + }, + "runs": [ + { + "id": 30499063682, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30499063682", + "headSha": "e3068e972fcf5ba21e814cba07bfc26c436076ac", + "createdAt": "2026-07-29T23:17:47Z", + "durationSeconds": 327, + "queueSeconds": 2, + "prepareSeconds": 145, + "artifactTransferSeconds": 27, + "jobCount": 21, + "declaredJobCount": 26 + }, + { + "id": 30498242402, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30498242402", + "headSha": "a932cbaaba95c6aff46a44aad77c2bd39cbef114", + "createdAt": "2026-07-29T23:03:07Z", + "durationSeconds": 339, + "queueSeconds": 2, + "prepareSeconds": 146, + "artifactTransferSeconds": 23, + "jobCount": 21, + "declaredJobCount": 26 + }, + { + "id": 30410294465, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30410294465", + "headSha": "137e969d831c2903d58875e0d4b535e28bb88590", + "createdAt": "2026-07-29T00:09:44Z", + "durationSeconds": 177, + "queueSeconds": 2, + "prepareSeconds": 105, + "artifactTransferSeconds": 7, + "jobCount": 15, + "declaredJobCount": 23 + }, + { + "id": 30326325961, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30326325961", + "headSha": "fca9ea7015db9bbe30f274713958d0a5a2a9ab0b", + "createdAt": "2026-07-28T03:35:13Z", + "durationSeconds": 376, + "queueSeconds": 2, + "prepareSeconds": 186, + "artifactTransferSeconds": 43, + "jobCount": 27, + "declaredJobCount": 27 + }, + { + "id": 30324995426, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30324995426", + "headSha": "593d7059b6eda6487d027664ca1dcbba2780682b", + "createdAt": "2026-07-28T03:06:12Z", + "durationSeconds": 554, + "queueSeconds": 3, + "prepareSeconds": 215, + "artifactTransferSeconds": 68, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30323815350, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30323815350", + "headSha": "2872b9d7740e26f65246edd7da68649a449d9515", + "createdAt": "2026-07-28T02:41:18Z", + "durationSeconds": 561, + "queueSeconds": 44, + "prepareSeconds": 203, + "artifactTransferSeconds": 62, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30320830245, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30320830245", + "headSha": "a25b633c6a17d5933ec8d789ed771574c3f74569", + "createdAt": "2026-07-28T01:38:17Z", + "durationSeconds": 551, + "queueSeconds": 2, + "prepareSeconds": 214, + "artifactTransferSeconds": 84, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30319606279, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30319606279", + "headSha": "b90dcdaf87beb100dde747dcd506fedca8eaa6dd", + "createdAt": "2026-07-28T01:12:46Z", + "durationSeconds": 525, + "queueSeconds": 3, + "prepareSeconds": 218, + "artifactTransferSeconds": 71, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30317128835, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30317128835", + "headSha": "8ecaae9b58cf4737e64e121c4f40a4b44d53ce29", + "createdAt": "2026-07-28T00:22:50Z", + "durationSeconds": 430, + "queueSeconds": 22, + "prepareSeconds": 219, + "artifactTransferSeconds": 27, + "jobCount": 24, + "declaredJobCount": 25 + }, + { + "id": 30314285296, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30314285296", + "headSha": "7109c7cea43873a35674a8771b07c2de471cd78c", + "createdAt": "2026-07-27T23:29:07Z", + "durationSeconds": 537, + "queueSeconds": 39, + "prepareSeconds": 181, + "artifactTransferSeconds": 70, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30311382751, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30311382751", + "headSha": "588861bf560552949315a723b07dec17609d0ad5", + "createdAt": "2026-07-27T22:37:26Z", + "durationSeconds": 549, + "queueSeconds": 2, + "prepareSeconds": 214, + "artifactTransferSeconds": 68, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30308921530, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30308921530", + "headSha": "bdf5a226b87c169f0d5ee572cbf453843ee1fd74", + "createdAt": "2026-07-27T21:57:24Z", + "durationSeconds": 552, + "queueSeconds": 3, + "prepareSeconds": 206, + "artifactTransferSeconds": 72, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30308226236, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30308226236", + "headSha": "16fb270ee955c8a75e92fa1c40680093bd96ce13", + "createdAt": "2026-07-27T21:46:35Z", + "durationSeconds": 556, + "queueSeconds": 2, + "prepareSeconds": 209, + "artifactTransferSeconds": 63, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30297586037, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30297586037", + "headSha": "d6d8d927e6bd1b850ecfbf9826d704c3e760b492", + "createdAt": "2026-07-27T19:17:19Z", + "durationSeconds": 390, + "queueSeconds": 61, + "prepareSeconds": 149, + "artifactTransferSeconds": 26, + "jobCount": 20, + "declaredJobCount": 25 + }, + { + "id": 30242635549, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30242635549", + "headSha": "c389a49721f61d3e4720aa4ad6359c89e9f9b576", + "createdAt": "2026-07-27T06:24:28Z", + "durationSeconds": 557, + "queueSeconds": 3, + "prepareSeconds": 231, + "artifactTransferSeconds": 70, + "jobCount": 49, + "declaredJobCount": 49 + }, + { + "id": 30236409071, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30236409071", + "headSha": "b33aa1f3af7e2fd0cddaba5f68cfd2525699c79b", + "createdAt": "2026-07-27T04:11:36Z", + "durationSeconds": 425, + "queueSeconds": 2, + "prepareSeconds": 239, + "artifactTransferSeconds": 22, + "jobCount": 30, + "declaredJobCount": 30 + }, + { + "id": 30235006722, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30235006722", + "headSha": "a59cdc604b87a367ef170c5c6c4793ffa2f49e30", + "createdAt": "2026-07-27T03:36:31Z", + "durationSeconds": 401, + "queueSeconds": 2, + "prepareSeconds": 216, + "artifactTransferSeconds": 24, + "jobCount": 30, + "declaredJobCount": 30 + }, + { + "id": 30234691195, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30234691195", + "headSha": "aaa0316ba7f1a6070126430cee650c705582a9d7", + "createdAt": "2026-07-27T03:28:38Z", + "durationSeconds": 401, + "queueSeconds": 2, + "prepareSeconds": 207, + "artifactTransferSeconds": 32, + "jobCount": 30, + "declaredJobCount": 30 + }, + { + "id": 30234688139, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30234688139", + "headSha": "22b5af1c2e6713093bdca07a504480aa23750551", + "createdAt": "2026-07-27T03:28:33Z", + "durationSeconds": 280, + "queueSeconds": 2, + "prepareSeconds": 135, + "artifactTransferSeconds": 17, + "jobCount": 18, + "declaredJobCount": 23 + }, + { + "id": 30234346065, + "url": "https://github.com/bsv-blockchain/ts-stack/actions/runs/30234346065", + "headSha": "b4dea247474c0a2da47404cd20262b12b6d3684c", + "createdAt": "2026-07-27T03:19:42Z", + "durationSeconds": 413, + "queueSeconds": 2, + "prepareSeconds": 217, + "artifactTransferSeconds": 32, + "jobCount": 30, + "declaredJobCount": 30 + } + ] + } + } +} diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index b921d160c..4cf367e48 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -168,8 +168,8 @@ "name": "@bsv/verifast", "publishedVersion": "0.3.0", "releaseType": "patch", - "summary": "Uses precise worker result type errors and refreshes the compatible browser verification test client.", - "migration": "No consumer migration is required; valid verification results and worker protocols are unchanged." + "summary": "Adds exact-tarball browser, WASM, worker, strict-CSP, streaming-fallback, UMD source-map, and raw/gzip/Brotli bundle contracts.", + "migration": "No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged." }, { "name": "@bsv/wallet-helper", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 235504201..3cf671dab 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -296,7 +296,7 @@ "@bsv/overlay-express": "2.4.7", "@bsv/overlay-topics": "1.6.6", "@bsv/sdk": "2.2.12", - "@bsv/verifast": "0.3.1", + "@bsv/verifast": "0.3.2", "@bsv/btms": "1.1.2", "@bsv/btms-permission-module": "1.1.1", "@bsv/wallet-relay": "0.3.0", diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index adcab49d0..0ec021702 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -6,6 +6,48 @@ "codeownersSource": ".github/CODEOWNERS" } }, + "consumerProfileDefinitions": { + "browser-bundler": { + "description": "Installed package bundled for production by supported browser bundlers", + "requiredRuntimeTargets": ["browser"], + "verification": "test:browser packs and installs the exact package graph, then exercises production bundling and source maps" + }, + "browser-esm": { + "description": "Installed package consumed through its browser ESM export", + "requiredRuntimeTargets": ["browser"], + "verification": "test:browser resolves the exact installed browser import condition and validates its public surface" + }, + "cli": { + "description": "Installed package executable invoked through its published bin mapping", + "requiredRuntimeTargets": ["node"], + "verification": "pack:check installs the exact tarball and executes the declared binary" + }, + "node-cjs": { + "description": "Installed package consumed with CommonJS require", + "requiredRuntimeTargets": ["node"], + "verification": "pack:check installs the exact tarball and exercises its CommonJS entry point" + }, + "node-esm": { + "description": "Installed package consumed with Node.js ESM import", + "requiredRuntimeTargets": ["node"], + "verification": "pack:check installs the exact tarball and exercises its ESM entry point" + }, + "react-native-metro": { + "description": "Installed package consumed by React Native through Metro-compatible resolution", + "requiredRuntimeTargets": ["react-native"], + "verification": "test:mobile packs the exact package graph and validates the React Native export and Metro bundle" + }, + "umd-global": { + "description": "Installed classic-browser bundle consumed through its documented global", + "requiredRuntimeTargets": ["browser", "umd"], + "verification": "test:browser measures and validates the exact packed UMD bundle and global contract" + }, + "wasm-worker": { + "description": "Installed browser package loading WebAssembly and dedicated workers", + "requiredRuntimeTargets": ["browser", "wasm", "worker"], + "verification": "test:consumers exercises the exact package's real WebAssembly and worker boundaries" + } + }, "dependencyAutomation": { "firstParty": { "pattern": "@bsv/*", @@ -321,6 +363,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-2", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -331,6 +374,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -341,6 +385,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "cli", + "consumerProfiles": ["cli"], "criticality": "tier-2", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -351,6 +396,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["browser", "node"], "release": "npm-oidc" @@ -361,6 +407,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm", "umd-global"], "criticality": "tier-1", "runtimeTargets": ["browser", "node", "umd"], "release": "npm-oidc" @@ -371,6 +418,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "cli", + "consumerProfiles": ["cli"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -381,6 +429,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["browser", "node"], "release": "npm-oidc" @@ -391,6 +440,7 @@ "owner": "ts-stack-maintainers", "area": "helpers", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], "criticality": "tier-2", "runtimeTargets": ["browser", "node"], "release": "npm-oidc" @@ -401,6 +451,7 @@ "owner": "ts-stack-maintainers", "area": "messaging", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -411,6 +462,7 @@ "owner": "ts-stack-maintainers", "area": "messaging", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm", "umd-global"], "criticality": "tier-1", "runtimeTargets": ["browser", "node", "umd"], "release": "npm-oidc" @@ -421,8 +473,9 @@ "owner": "ts-stack-maintainers", "area": "messaging", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm", "umd-global"], "criticality": "tier-1", - "runtimeTargets": ["browser", "node"], + "runtimeTargets": ["browser", "node", "umd"], "release": "npm-oidc" }, { @@ -431,6 +484,7 @@ "owner": "ts-stack-maintainers", "area": "messaging", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["browser", "node"], "declarationDependencies": ["@types/express"], @@ -452,6 +506,7 @@ "owner": "ts-stack-maintainers", "area": "middleware", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["browser", "node"], "release": "npm-oidc" @@ -462,6 +517,7 @@ "owner": "ts-stack-maintainers", "area": "middleware", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -472,6 +528,7 @@ "owner": "ts-stack-maintainers", "area": "middleware", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "declarationDependencies": ["@types/express"], @@ -483,6 +540,7 @@ "owner": "ts-stack-maintainers", "area": "middleware", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "declarationDependencies": ["@types/express"], @@ -494,6 +552,7 @@ "owner": "ts-stack-maintainers", "area": "network", "profile": "node-library", + "consumerProfiles": ["node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -514,6 +573,7 @@ "owner": "ts-stack-maintainers", "area": "overlays", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["browser", "node"], "release": "npm-oidc" @@ -524,6 +584,7 @@ "owner": "ts-stack-maintainers", "area": "overlays", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -534,6 +595,7 @@ "owner": "ts-stack-maintainers", "area": "overlays", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -544,6 +606,7 @@ "owner": "ts-stack-maintainers", "area": "overlays", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "declarationDependencies": ["@types/express"], @@ -555,6 +618,7 @@ "owner": "ts-stack-maintainers", "area": "overlays", "profile": "node-library", + "consumerProfiles": ["node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -565,6 +629,7 @@ "owner": "ts-stack-maintainers", "area": "sdk", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm", "umd-global"], "criticality": "tier-0", "runtimeTargets": ["browser", "node", "umd"], "release": "npm-oidc" @@ -575,8 +640,16 @@ "owner": "ts-stack-maintainers", "area": "sdk", "profile": "wasm-library", + "consumerProfiles": [ + "browser-bundler", + "browser-esm", + "node-cjs", + "node-esm", + "umd-global", + "wasm-worker" + ], "criticality": "tier-0", - "runtimeTargets": ["browser", "node", "wasm", "worker"], + "runtimeTargets": ["browser", "node", "umd", "wasm", "worker"], "release": "npm-oidc" }, { @@ -585,6 +658,7 @@ "owner": "ts-stack-maintainers", "area": "wallet", "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -595,6 +669,7 @@ "owner": "ts-stack-maintainers", "area": "wallet", "profile": "node-library", + "consumerProfiles": ["node-esm"], "criticality": "tier-1", "runtimeTargets": ["node"], "release": "npm-oidc" @@ -605,8 +680,9 @@ "owner": "ts-stack-maintainers", "area": "wallet", "profile": "cli-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "cli", "node-cjs", "node-esm"], "criticality": "tier-1", - "runtimeTargets": ["node"], + "runtimeTargets": ["browser", "node"], "declarationDependencies": ["@types/express"], "release": "npm-oidc" }, @@ -616,6 +692,7 @@ "owner": "ts-stack-maintainers", "area": "wallet", "profile": "node-library", + "consumerProfiles": ["node-cjs"], "criticality": "tier-0", "runtimeTargets": ["node"], "declarationDependencies": ["@types/express"], @@ -637,6 +714,7 @@ "owner": "ts-stack-maintainers", "area": "wallet", "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], "criticality": "tier-1", "runtimeTargets": ["browser", "node"], "release": "npm-oidc" @@ -647,6 +725,7 @@ "owner": "ts-stack-maintainers", "area": "wallet", "profile": "react-native-library", + "consumerProfiles": ["react-native-metro"], "criticality": "tier-1", "runtimeTargets": ["react-native"], "release": "npm-oidc" diff --git a/packages/verifast/README.md b/packages/verifast/README.md index e7e3c4785..8c78aea1e 100644 --- a/packages/verifast/README.md +++ b/packages/verifast/README.md @@ -176,7 +176,9 @@ the SDK implementation. Worker scheduling and its internal table-snapshot ABI remain in the full ESM/worker artifact, so the classic path ships only the public typed verifier and cryptography APIs. The build rejects a complete classic-script payload over 300,000 bytes, counting both loaders and WASM; the -current payload is 298,567 bytes. +current payload is 298,753 bytes. Exact-tarball browser checks additionally +enforce raw, gzip, and Brotli Vite, esbuild, and complete classic-payload +budgets. An optional custom WASM factory remains supported: @@ -241,8 +243,10 @@ The coverage gate ratchets statements, branches, functions, and lines. validates conditional declarations and raw WASM assets. The deterministic corpus compares positive and negative SDK-interpreter verdicts with real BDK WASM for whole transactions and individual Spend objects. -Consumer tests execute the built package through Node ESM, CommonJS, browser -ESM, and browser UMD rather than substituting mocks. +Consumer tests install the exact tarball and execute it through Node ESM, +CommonJS, Vite, esbuild, real browser ESM/workers, and browser UMD rather than +substituting mocks. Browser runtime checks enforce a strict same-origin CSP and +exercise both streaming WASM compilation and the ArrayBuffer fallback. On the retained Apple M3 Max baseline, BDK is 16–20x faster in Node and 12–15x faster in Chrome for P2PKH transactions; TypeScript remains faster for trivial diff --git a/packages/verifast/browser-budget.json b/packages/verifast/browser-budget.json new file mode 100644 index 000000000..7b844ead1 --- /dev/null +++ b/packages/verifast/browser-budget.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/verifast", + "entry": ".", + "requiredExports": [ + "BDK_FLAG_BITS", + "BdkErrorDomain", + "BdkVerificationError", + "BdkVerifier", + "mapVerifyFlags" + ], + "prohibitedExports": [], + "maximumBytes": { + "vite": { + "raw": 805000, + "gzip": 321000, + "brotli": 138000 + }, + "esbuild": { + "raw": 42000, + "gzip": 14200, + "brotli": 12700 + } + }, + "umd": { + "path": "dist/umd/verifast.js", + "additionalPaths": ["dist/src/wasm/bdk-core.umd.js", "dist/src/wasm/bdk-core.umd.wasm"], + "global": "bsvVerifast", + "maximumBytes": { + "raw": 300000, + "gzip": 121000, + "brotli": 95000 + } + } +} diff --git a/packages/verifast/browser/fallback.html b/packages/verifast/browser/fallback.html new file mode 100644 index 000000000..3cea0228c --- /dev/null +++ b/packages/verifast/browser/fallback.html @@ -0,0 +1,11 @@ + + + + + VeriFast WASM fallback integration + + +
running
+ + + diff --git a/packages/verifast/browser/fallback.ts b/packages/verifast/browser/fallback.ts new file mode 100644 index 000000000..65f46a363 --- /dev/null +++ b/packages/verifast/browser/fallback.ts @@ -0,0 +1,18 @@ +import { BdkVerifier } from '@bsv/verifast' + +declare global { + interface Window { + __VERIFAST_FALLBACK_RESULT__?: boolean + __VERIFAST_FALLBACK_ERROR__?: string + } +} + +try { + const verifier = new BdkVerifier({ registerAsDefault: false }) + await verifier.preload() + window.__VERIFAST_FALLBACK_RESULT__ = verifier.isReady() + document.querySelector('#result')!.textContent = String(window.__VERIFAST_FALLBACK_RESULT__) +} catch (error) { + window.__VERIFAST_FALLBACK_ERROR__ = + error instanceof Error ? (error.stack ?? error.message) : String(error) +} diff --git a/packages/verifast/browser/main.ts b/packages/verifast/browser/main.ts index 575ea7908..71f5ce6a9 100644 --- a/packages/verifast/browser/main.ts +++ b/packages/verifast/browser/main.ts @@ -1,4 +1,4 @@ -import { BdkVerifier } from '../mod.browser.js' +import { BdkVerifier } from '@bsv/verifast' import { buildCorpus } from '../bench/corpus.js' interface BrowserResult { diff --git a/packages/verifast/browser/test.mjs b/packages/verifast/browser/test.mjs index 2792befb8..cf00d19d1 100644 --- a/packages/verifast/browser/test.mjs +++ b/packages/verifast/browser/test.mjs @@ -1,54 +1,208 @@ import assert from 'node:assert/strict' -import { access } from 'node:fs/promises' -import { createServer } from 'vite' +import { cp, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + import puppeteer from 'puppeteer-core' +import { createServer } from 'vite' -const packageDir = new URL('../', import.meta.url) +import { createCommandRunner } from '../../../scripts/lib/command-runner.mjs' + +const COMMAND_TIMEOUT_MS = 240_000 +const CSP = + "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; " + + "connect-src 'self'; style-src 'none'; img-src 'none'; object-src 'none'; base-uri 'none'" +const packageDirectory = fileURLToPath(new URL('../', import.meta.url)) +const repositoryRoot = path.resolve(packageDirectory, '../..') +const run = createCommandRunner({ + timeoutMs: COMMAND_TIMEOUT_MS, + maxBufferBytes: 30 * 1024 * 1024, + maxErrorOutputCharacters: 16_000 +}) const chromeCandidates = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/chromium' -].filter(Boolean) +] + +async function removeTemporaryDirectory(directory) { + await rm(directory, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100 + }) +} + +async function pack(directory, destination) { + const manifest = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')) + const { stdout } = await run('pnpm', ['pack', '--json', '--pack-destination', destination], { + cwd: directory, + env: { ...process.env, npm_config_ignore_scripts: 'true' } + }) + const result = JSON.parse(stdout) + assert.equal(result.name, manifest.name) + return path.resolve(result.filename) +} -let executablePath -for (const candidate of chromeCandidates) { +async function createExactPackageConsumer() { + const packDirectory = await mkdtemp(path.join(tmpdir(), 'verifast-browser-pack-')) + const consumerDirectory = await mkdtemp(path.join(tmpdir(), 'verifast-browser-consumer-')) try { - await access(candidate) - executablePath = candidate - break - } catch {} + const tarballs = await Promise.all([ + pack(packageDirectory, packDirectory), + pack(path.join(repositoryRoot, 'packages/sdk'), packDirectory) + ]) + await writeFile( + path.join(consumerDirectory, 'package.json'), + `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n` + ) + await run( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--omit=dev', + ...tarballs + ], + { cwd: consumerDirectory } + ) + await Promise.all([ + mkdir(path.join(consumerDirectory, 'browser')), + mkdir(path.join(consumerDirectory, 'bench')) + ]) + await Promise.all( + ['fallback.html', 'fallback.ts', 'index.html', 'main.ts', 'umd.html', 'umd-main.js'].map( + file => + cp( + path.join(packageDirectory, 'browser', file), + path.join(consumerDirectory, 'browser', file) + ) + ) + ) + await cp( + path.join(packageDirectory, 'bench/corpus.ts'), + path.join(consumerDirectory, 'bench/corpus.ts') + ) + return { consumerDirectory, packDirectory } + } catch (error) { + await Promise.all([ + removeTemporaryDirectory(packDirectory), + removeTemporaryDirectory(consumerDirectory) + ]) + throw error + } +} + +async function executablePath() { + const { access } = await import('node:fs/promises') + for (const candidate of chromeCandidates) { + try { + await access(candidate) + return candidate + } catch {} + } + throw new Error('Chrome or Chromium was not found in a supported default location') +} + +function collectPageErrors(page) { + const errors = [] + page.on('pageerror', error => errors.push(error.stack ?? error.message)) + page.on('requestfailed', request => { + errors.push(`${request.url()}: ${request.failure()?.errorText ?? 'request failed'}`) + }) + return errors } -assert.ok(executablePath, 'Chrome or Chromium was not found in a supported default location') +async function forceStreamingFallback(page) { + const session = await page.createCDPSession() + let interceptions = 0 + const failures = [] + await session.send('Fetch.enable', { + patterns: [{ urlPattern: '*.wasm*', requestStage: 'Response' }] + }) + session.on('Fetch.requestPaused', event => { + void (async () => { + try { + if (event.responseStatusCode === undefined) { + await session.send('Fetch.continueRequest', { requestId: event.requestId }) + return + } + const response = await session.send('Fetch.getResponseBody', { + requestId: event.requestId + }) + const responseHeaders = (event.responseHeaders ?? []).filter( + header => header.name.toLowerCase() !== 'content-type' + ) + responseHeaders.push({ name: 'Content-Type', value: 'application/octet-stream' }) + await session.send('Fetch.fulfillRequest', { + requestId: event.requestId, + responseCode: event.responseStatusCode, + responsePhrase: event.responseStatusText, + responseHeaders, + body: response.base64Encoded + ? response.body + : Buffer.from(response.body).toString('base64') + }) + interceptions += 1 + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)) + } + })() + }) + return { + close: async () => await session.send('Fetch.disable'), + failures, + interceptions: () => interceptions + } +} + +const temporary = await createExactPackageConsumer() +const realConsumerDirectory = await realpath(temporary.consumerDirectory) const server = await createServer({ - root: packageDir.pathname, + root: temporary.consumerDirectory, logLevel: 'error', + optimizeDeps: { + exclude: ['@bsv/sdk', '@bsv/verifast'] + }, server: { + headers: { 'Content-Security-Policy': CSP }, host: '127.0.0.1', port: 0, - fs: { allow: [packageDir.pathname] } + fs: { allow: [temporary.consumerDirectory, realConsumerDirectory] } } }) let browser try { await server.listen() - const url = new URL('browser/index.html', server.resolvedUrls.local[0]).href + const baseUrl = server.resolvedUrls.local[0] browser = await puppeteer.launch({ - executablePath, + executablePath: await executablePath(), headless: true, args: ['--no-sandbox'] }) + const page = await browser.newPage() - const browserErrors = [] - page.on('pageerror', error => browserErrors.push(error.stack ?? error.message)) - await page.goto(url, { waitUntil: 'networkidle0' }) + const browserErrors = collectPageErrors(page) + const wasmContentTypes = [] + page.on('response', response => { + if (new URL(response.url()).pathname.endsWith('.wasm')) { + wasmContentTypes.push(response.headers()['content-type']) + } + }) + const response = await page.goto(new URL('browser/index.html', baseUrl).href, { + waitUntil: 'networkidle0' + }) + assert.equal(response.headers()['content-security-policy'], CSP) await page.waitForFunction( () => window.__VERIFAST_RESULT__ !== undefined || window.__VERIFAST_ERROR__ !== undefined, - { - timeout: 60_000 - } + { timeout: 60_000 } ) const state = await page.evaluate(() => ({ result: window.__VERIFAST_RESULT__, @@ -56,6 +210,7 @@ try { })) assert.equal(state.error, undefined, state.error) assert.deepEqual(browserErrors, []) + assert.ok(wasmContentTypes.includes('application/wasm'), 'normal WASM uses streaming MIME') for (const vector of state.result.vectors) { assert.equal(vector.js, vector.expected, `${vector.name} JS`) assert.equal(vector.bdk, vector.expected, `${vector.name} BDK`) @@ -69,10 +224,38 @@ try { assert.ok(benchmark.bdkInputsPerSecond > 0) } + const fallbackPage = await browser.newPage() + const fallbackErrors = collectPageErrors(fallbackPage) + const fallbackLogs = [] + fallbackPage.on('console', message => fallbackLogs.push(message.text())) + const fallback = await forceStreamingFallback(fallbackPage) + await fallbackPage.goto(new URL('browser/fallback.html', baseUrl).href, { + waitUntil: 'networkidle0' + }) + await fallbackPage.waitForFunction( + () => + window.__VERIFAST_FALLBACK_RESULT__ !== undefined || + window.__VERIFAST_FALLBACK_ERROR__ !== undefined, + { timeout: 60_000 } + ) + const fallbackState = await fallbackPage.evaluate(() => ({ + result: window.__VERIFAST_FALLBACK_RESULT__, + error: window.__VERIFAST_FALLBACK_ERROR__ + })) + await fallback.close() + assert.equal(fallbackState.error, undefined, fallbackState.error) + assert.equal(fallbackState.result, true) + assert.deepEqual(fallbackErrors, []) + assert.deepEqual(fallback.failures, []) + assert.ok(fallback.interceptions() > 0, 'WASM response MIME was intercepted') + assert.ok( + fallbackLogs.some(message => message.includes('falling back to ArrayBuffer instantiation')), + 'Emscripten reported its ArrayBuffer fallback' + ) + const umdPage = await browser.newPage() - const umdErrors = [] - umdPage.on('pageerror', error => umdErrors.push(error.stack ?? error.message)) - await umdPage.goto(new URL('umd.html', url).href, { waitUntil: 'networkidle0' }) + const umdErrors = collectPageErrors(umdPage) + await umdPage.goto(new URL('browser/umd.html', baseUrl).href, { waitUntil: 'networkidle0' }) await umdPage.waitForFunction( () => window.__VERIFAST_UMD_RESULT__ !== undefined || window.__VERIFAST_UMD_ERROR__ !== undefined, @@ -85,9 +268,13 @@ try { assert.equal(umdState.error, undefined, umdState.error) assert.equal(umdState.result, true) assert.deepEqual(umdErrors, []) - console.log('ok - browser UMD loader and package wrapper') + console.log('ok - exact packed browser ESM, WASM, workers, strict CSP, fallback, and UMD') console.log(JSON.stringify(state.result, null, 2)) } finally { await browser?.close() await server.close() + await Promise.all([ + removeTemporaryDirectory(temporary.packDirectory), + removeTemporaryDirectory(temporary.consumerDirectory) + ]) } diff --git a/packages/verifast/browser/umd-main.js b/packages/verifast/browser/umd-main.js new file mode 100644 index 000000000..8c939ebc1 --- /dev/null +++ b/packages/verifast/browser/umd-main.js @@ -0,0 +1,23 @@ +const hex = + '010000000000000000ef0120fa0d2c5974cfe6e3aec71f7f6539cfa1c1e474082d2cdb41fb830f6267b7d7000000006b4830450221008788b545ebd6ebcb15f938045b71c1fa7efafd55d1f4e64e96602a04f3214cda0220717ddadfa7d1dc6a22ccb077350aef7a2073ee58fe780d86b77a31c24c664a21412103ef28c47337b05ec3f14b63d904db7ae023e897389dbdbf531221e13fd5e5b105ffffffffdc3de103000000001976a91437fb14a40d021abbb1763497f963a130286d1ad188ac017239e103000000001976a914962eba38504bcfb140ff0246afa795658812b42788ac00000000' +const bytes = new Uint8Array(hex.length / 2) +for (let index = 0; index < bytes.length; index++) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16) +} + +try { + const verifier = new window.bsvVerifast.BdkVerifier() + if (verifier.isReady()) throw new Error('VeriFast must remain lazy before preload') + await verifier.preload() + if (!verifier.isReady()) throw new Error('VeriFast did not report ready after preload') + const result = await verifier.verifyScriptsFromEF({ + extendedTransaction: bytes, + utxoHeights: [574441], + blockHeight: 620940, + consensus: true + }) + window.__VERIFAST_UMD_RESULT__ = result + document.querySelector('#result').textContent = String(result) +} catch (error) { + window.__VERIFAST_UMD_ERROR__ = error.stack || error.message +} diff --git a/packages/verifast/browser/umd.html b/packages/verifast/browser/umd.html index 6b3967b44..890cd8844 100644 --- a/packages/verifast/browser/umd.html +++ b/packages/verifast/browser/umd.html @@ -1,30 +1,13 @@ - VeriFast UMD integration + + + VeriFast UMD integration +
running
- - - + + + diff --git a/packages/verifast/package.json b/packages/verifast/package.json index b5d8d2f33..4c2aab781 100644 --- a/packages/verifast/package.json +++ b/packages/verifast/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/verifast", - "version": "0.3.1", + "version": "0.3.2", "sideEffects": false, "publishConfig": { "access": "public" @@ -78,8 +78,8 @@ "bench:batch": "node --expose-gc --import tsx bench/batch-benchmark.ts", "bench:crypto": "node --import tsx bench/crypto-benchmark.ts", "bench:warmup": "node --import tsx bench/warmup-benchmark.ts", - "test:browser": "pnpm build && node browser/test.mjs", - "test:consumers": "pnpm build && node scripts/test-node-consumers.mjs && node scripts/test-browser-bundler.mjs && node browser/test.mjs", + "test:browser": "pnpm build && node ../../scripts/check-browser-package.mjs . && node browser/test.mjs", + "test:consumers": "pnpm build && node scripts/test-node-consumers.mjs && node scripts/test-browser-bundler.mjs && node ../../scripts/check-browser-package.mjs . && node browser/test.mjs", "typecheck": "tsc -p tsconfig.json", "prepublishOnly": "pnpm build" }, diff --git a/packages/verifast/rspack.config.js b/packages/verifast/rspack.config.js index 66c40fd6f..9f9f1c8d5 100644 --- a/packages/verifast/rspack.config.js +++ b/packages/verifast/rspack.config.js @@ -6,6 +6,7 @@ const packageDir = path.dirname(fileURLToPath(import.meta.url)) export default { mode: 'production', + devtool: 'source-map', entry: './dist/umd.js', output: { filename: 'verifast.cjs', diff --git a/packages/verifast/scripts/copy-umd.mjs b/packages/verifast/scripts/copy-umd.mjs index 6bc4a91bd..9d5c5e431 100644 --- a/packages/verifast/scripts/copy-umd.mjs +++ b/packages/verifast/scripts/copy-umd.mjs @@ -1,6 +1,14 @@ -import { copyFile } from 'node:fs/promises' +import { copyFile, readFile, writeFile } from 'node:fs/promises' await copyFile( new URL('../dist/umd/verifast.cjs', import.meta.url), new URL('../dist/umd/verifast.js', import.meta.url) ) +await copyFile( + new URL('../dist/umd/verifast.cjs.map', import.meta.url), + new URL('../dist/umd/verifast.js.map', import.meta.url) +) + +const browserBundle = new URL('../dist/umd/verifast.js', import.meta.url) +const source = await readFile(browserBundle, 'utf8') +await writeFile(browserBundle, source.replace('verifast.cjs.map', 'verifast.js.map')) diff --git a/packages/verifast/tsconfig.build.json b/packages/verifast/tsconfig.build.json index 1df97788b..ec777e6f4 100644 --- a/packages/verifast/tsconfig.build.json +++ b/packages/verifast/tsconfig.build.json @@ -4,6 +4,7 @@ "outDir": "dist", "rootDir": ".", "declaration": true, + "inlineSources": true, "types": ["node"], "tsBuildInfoFile": "node_modules/.cache/verifast.tsbuildinfo" }, diff --git a/scripts/check-browser-package.mjs b/scripts/check-browser-package.mjs index 4a5946de5..fd78fa54c 100644 --- a/scripts/check-browser-package.mjs +++ b/scripts/check-browser-package.mjs @@ -89,6 +89,17 @@ export function bundleSizes(buffer) { } } +export function aggregateBundleSizes(buffers) { + return buffers.map(bundleSizes).reduce( + (total, sizes) => ({ + raw: total.raw + sizes.raw, + gzip: total.gzip + sizes.gzip, + brotli: total.brotli + sizes.brotli + }), + { raw: 0, gzip: 0, brotli: 0 } + ) +} + function positiveBudget(value, label) { if (!Number.isSafeInteger(value) || value <= 0) { throw new Error(`${label} must be a positive safe integer`) @@ -175,6 +186,13 @@ export function validateBrowserBudget(budget, manifest) { throw new Error(`browser budget umd.${field} must be a non-empty string`) } } + if (budget.umd.additionalPaths !== undefined) { + validateStringArray(budget.umd.additionalPaths, 'browser budget umd.additionalPaths') + const paths = [budget.umd.path, ...budget.umd.additionalPaths] + if (new Set(paths).size !== paths.length) { + throw new Error('browser budget umd paths must be unique') + } + } validateBundleBudget({ raw: 0, gzip: 0, brotli: 0 }, budget.umd.maximumBytes, 'umd') } } @@ -369,14 +387,29 @@ async function removeTemporaryDirectory(directory) { async function checkUmd(consumerDirectory, manifest, budget) { if (!budget.umd) return undefined const packageDirectory = installedPackageDirectory(consumerDirectory, manifest.name) - const bundlePath = path.resolve(packageDirectory, budget.umd.path) - if (!bundlePath.startsWith(`${packageDirectory}${path.sep}`)) { - throw new Error(`UMD path escapes installed package: ${budget.umd.path}`) - } - const [code, sourceMapText] = await Promise.all([ - fs.readFile(bundlePath), + const payloadPaths = [budget.umd.path, ...(budget.umd.additionalPaths ?? [])] + const absolutePayloadPaths = payloadPaths.map(relativePath => { + const absolutePath = path.resolve(packageDirectory, relativePath) + if (!absolutePath.startsWith(`${packageDirectory}${path.sep}`)) { + throw new Error(`UMD path escapes installed package: ${relativePath}`) + } + return absolutePath + }) + const bundlePath = absolutePayloadPaths[0] + const [payloads, sourceMapText] = await Promise.all([ + Promise.all(absolutePayloadPaths.map(payloadPath => fs.readFile(payloadPath))), fs.readFile(`${bundlePath}.map`, 'utf8') ]) + for (const [index, payloadPath] of absolutePayloadPaths.entries()) { + if (/\.[cm]?js$/.test(payloadPath)) { + assertBrowserComposition( + [], + payloads[index].toString('utf8'), + `UMD payload ${payloadPaths[index]}` + ) + } + } + const code = payloads[0] const sourceMap = JSON.parse(sourceMapText) if ( !Array.isArray(sourceMap.sources) || @@ -390,9 +423,8 @@ async function checkUmd(consumerDirectory, manifest, budget) { if (!codeText.includes(budget.umd.global)) { throw new Error(`UMD bundle does not expose configured global ${budget.umd.global}`) } - assertBrowserComposition([], codeText, 'UMD browser bundle') - const measurements = bundleSizes(code) - validateBundleBudget(measurements, budget.umd.maximumBytes, 'UMD browser bundle') + const measurements = aggregateBundleSizes(payloads) + validateBundleBudget(measurements, budget.umd.maximumBytes, 'UMD browser payload') return measurements } diff --git a/scripts/check-browser-package.test.mjs b/scripts/check-browser-package.test.mjs index 296907b8b..961acbab6 100644 --- a/scripts/check-browser-package.test.mjs +++ b/scripts/check-browser-package.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { + aggregateBundleSizes, bundleSizes, prohibitedModuleIds, prohibitedRuntimeSpecifiers, @@ -9,6 +10,16 @@ import { validateBundleBudget } from './check-browser-package.mjs' +test('multi-file payload sizes sum each independently transferred representation', () => { + const payloads = [Buffer.from('alpha alpha alpha'), Buffer.from('alpha alpha alpha')] + const individual = bundleSizes(payloads[0]) + assert.deepEqual(aggregateBundleSizes(payloads), { + raw: individual.raw * 2, + gzip: individual.gzip * 2, + brotli: individual.brotli * 2 + }) +}) + test('browser bundle composition rejects Node and server dependencies', () => { assert.deepEqual( prohibitedModuleIds([ @@ -47,6 +58,12 @@ test('browser budget metadata is bound to the package and contract', () => { maximumBytes: { vite: { raw: 1, gzip: 1, brotli: 1 }, esbuild: { raw: 1, gzip: 1, brotli: 1 } + }, + umd: { + path: 'dist/example.js', + additionalPaths: ['dist/example.wasm'], + global: 'example', + maximumBytes: { raw: 1, gzip: 1, brotli: 1 } } } assert.doesNotThrow(() => validateBrowserBudget(budget, manifest)) @@ -54,4 +71,15 @@ test('browser budget metadata is bound to the package and contract', () => { () => validateBrowserBudget({ ...budget, package: '@bsv/other' }, manifest), /does not match/ ) + assert.throws( + () => + validateBrowserBudget( + { + ...budget, + umd: { ...budget.umd, additionalPaths: ['dist/example.js'] } + }, + manifest + ), + /paths must be unique/ + ) }) diff --git a/scripts/ci-performance.mjs b/scripts/ci-performance.mjs new file mode 100644 index 000000000..7f421036f --- /dev/null +++ b/scripts/ci-performance.mjs @@ -0,0 +1,434 @@ +#!/usr/bin/env node + +import { appendFile, readFile, writeFile } from 'node:fs/promises' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const DEFAULT_REPOSITORY = 'bsv-blockchain/ts-stack' +const DEFAULT_SAMPLE_SIZE = 20 +const FULL_SCOPE_MINIMUM_JOBS = 50 +const MAX_RUNS = 100 + +function secondsBetween(start, end) { + if (!start || !end) return null + const milliseconds = new Date(end).valueOf() - new Date(start).valueOf() + return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null +} + +function finite(values) { + return values.filter(value => Number.isFinite(value)) +} + +export function percentile(values, fraction) { + const sorted = finite(values).sort((left, right) => left - right) + if (sorted.length === 0) return null + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)] +} + +export function summarize(values) { + const samples = finite(values) + if (samples.length === 0) { + return { + samples: 0, + minimum: null, + median: null, + p95: null, + maximum: null, + standardDeviation: null + } + } + const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length + const variance = samples.reduce((sum, value) => sum + (value - mean) ** 2, 0) / samples.length + return { + samples: samples.length, + minimum: Math.min(...samples), + median: percentile(samples, 0.5), + p95: percentile(samples, 0.95), + maximum: Math.max(...samples), + standardDeviation: Math.round(Math.sqrt(variance)) + } +} + +function stepMetrics(step) { + return { + name: step.name, + conclusion: step.conclusion, + durationSeconds: secondsBetween(step.started_at, step.completed_at) + } +} + +export function measureRun(run, jobs) { + const startedJobs = jobs.filter(job => job.started_at) + const earliestJob = [...startedJobs].sort((left, right) => + left.started_at.localeCompare(right.started_at) + )[0] + const measuredJobs = jobs.map(job => ({ + name: job.name, + conclusion: job.conclusion, + queueSeconds: secondsBetween(run.created_at, job.started_at), + durationSeconds: secondsBetween(job.started_at, job.completed_at), + steps: (job.steps ?? []).map(stepMetrics) + })) + const artifactTransferSeconds = measuredJobs + .flatMap(job => job.steps) + .filter(step => + /\b(?:upload|download)\b.*\bartifact\b|\bartifact\b.*\b(?:upload|download)\b/i.test(step.name) + ) + .reduce((sum, step) => sum + (step.durationSeconds ?? 0), 0) + const prepareJob = measuredJobs.find(job => + /^(?:Build, lint, and policy|Prepare(?:\s*\/|$))/i.test(job.name) + ) + const executedJobs = jobs.filter(job => job.conclusion !== 'skipped') + return { + id: run.id, + url: run.html_url, + headSha: run.head_sha, + createdAt: run.created_at, + updatedAt: run.updated_at, + durationSeconds: secondsBetween(run.created_at, run.updated_at), + queueSeconds: secondsBetween(run.created_at, earliestJob?.started_at), + prepareSeconds: prepareJob?.durationSeconds ?? null, + artifactTransferSeconds, + jobCount: executedJobs.length, + declaredJobCount: jobs.length, + jobs: measuredJobs + } +} + +export function classifyRun(run, minimumJobs = FULL_SCOPE_MINIMUM_JOBS) { + return run.jobCount >= minimumJobs ? 'fullScope' : 'targeted' +} + +function groupSummary(runs) { + return { + runCount: runs.length, + durationSeconds: summarize(runs.map(run => run.durationSeconds)), + queueSeconds: summarize(runs.map(run => run.queueSeconds)), + prepareSeconds: summarize(runs.map(run => run.prepareSeconds)), + artifactTransferSeconds: summarize(runs.map(run => run.artifactTransferSeconds)), + jobCount: summarize(runs.map(run => run.jobCount)) + } +} + +export function createReport({ + repository, + workflow, + collectedAt, + sampleSize, + minimumJobs, + groups +}) { + return { + schemaVersion: 1, + collectedAt, + source: { + repository, + workflow, + event: 'pull_request', + conclusion: 'success' + }, + classification: { + fullScopeMinimumJobs: minimumJobs, + sampleSizePerClass: sampleSize + }, + observability: { + captured: [ + 'run duration and variance', + 'per-job and per-step duration', + 'job queue time', + 'artifact upload/download step duration' + ], + unavailableFromActionsApi: [ + 'hosted-runner CPU utilization', + 'hosted-runner memory utilization', + 'action-internal cache hit rate' + ], + note: 'Unavailable metrics require explicit in-run instrumentation; they are not inferred from the GitHub Actions REST API.' + }, + groups: Object.fromEntries( + Object.entries(groups).map(([name, runs]) => [name, { summary: groupSummary(runs), runs }]) + ) + } +} + +function compactRun(run) { + return { + id: run.id, + url: run.url, + headSha: run.headSha, + createdAt: run.createdAt, + durationSeconds: run.durationSeconds, + queueSeconds: run.queueSeconds, + prepareSeconds: run.prepareSeconds, + artifactTransferSeconds: run.artifactTransferSeconds, + jobCount: run.jobCount, + declaredJobCount: run.declaredJobCount + } +} + +export function createBaseline(report) { + return { + schemaVersion: 1, + recordedAt: report.collectedAt, + source: report.source, + classification: report.classification, + regressionBudget: { + fullScope: { medianPercent: 15, p95Percent: 20 }, + targeted: { medianPercent: 20, p95Percent: 25 } + }, + observability: report.observability, + reference: Object.fromEntries( + Object.entries(report.groups).map(([name, group]) => [ + name, + { + summary: group.summary, + runs: group.runs.map(compactRun) + } + ]) + ) + } +} + +export function validateBaseline(baseline) { + const errors = [] + if (baseline?.schemaVersion !== 1) errors.push('baseline schemaVersion must be 1') + if ( + baseline?.source?.repository !== DEFAULT_REPOSITORY || + baseline?.source?.workflow !== 'ci.yml' || + baseline?.source?.event !== 'pull_request' || + baseline?.source?.conclusion !== 'success' + ) { + errors.push('baseline source must be successful ts-stack pull-request ci.yml runs') + } + if (baseline?.classification?.sampleSizePerClass !== DEFAULT_SAMPLE_SIZE) { + errors.push(`baseline sampleSizePerClass must be ${DEFAULT_SAMPLE_SIZE}`) + } + if (baseline?.classification?.fullScopeMinimumJobs !== FULL_SCOPE_MINIMUM_JOBS) { + errors.push(`baseline fullScopeMinimumJobs must be ${FULL_SCOPE_MINIMUM_JOBS}`) + } + for (const name of ['fullScope', 'targeted']) { + const reference = baseline?.reference?.[name] + if (reference?.runs?.length !== DEFAULT_SAMPLE_SIZE) { + errors.push(`baseline ${name} must retain ${DEFAULT_SAMPLE_SIZE} run samples`) + } + if (reference?.summary?.runCount !== DEFAULT_SAMPLE_SIZE) { + errors.push(`baseline ${name} summary runCount must be ${DEFAULT_SAMPLE_SIZE}`) + } + const runs = Array.isArray(reference?.runs) ? reference.runs : [] + const expectedFullScope = name === 'fullScope' + for (const run of runs) { + if ( + !Number.isSafeInteger(run?.id) || + run.id <= 0 || + run.url !== `https://github.com/${DEFAULT_REPOSITORY}/actions/runs/${run.id}` || + !/^[0-9a-f]{40}$/.test(run?.headSha ?? '') || + !Number.isFinite(Date.parse(run?.createdAt)) + ) { + errors.push(`baseline ${name} contains an invalid exact run reference`) + break + } + if (run.jobCount >= FULL_SCOPE_MINIMUM_JOBS !== expectedFullScope) { + errors.push(`baseline ${name} contains a run in the wrong job-count class`) + break + } + } + if ( + runs.length === DEFAULT_SAMPLE_SIZE && + JSON.stringify(reference.summary) !== JSON.stringify(groupSummary(runs)) + ) { + errors.push(`baseline ${name} summary must match its retained run samples`) + } + for (const metric of ['median', 'p95']) { + if (!Number.isFinite(reference?.summary?.durationSeconds?.[metric])) { + errors.push(`baseline ${name} duration ${metric} must be finite`) + } + } + for (const metric of ['medianPercent', 'p95Percent']) { + const value = baseline?.regressionBudget?.[name]?.[metric] + if (!Number.isFinite(value) || value <= 0 || value > 100) { + errors.push(`baseline ${name} ${metric} must be between 1 and 100`) + } + } + } + const allRuns = Object.values(baseline?.reference ?? {}).flatMap(reference => + Array.isArray(reference?.runs) ? reference.runs : [] + ) + if (new Set(allRuns.map(run => run.id)).size !== allRuns.length) { + errors.push('baseline run ids must be unique') + } + if (new Set(allRuns.map(run => run.headSha)).size !== allRuns.length) { + errors.push('baseline run head SHAs must be unique') + } + return errors +} + +export function compareToBaseline(report, baseline) { + const errors = validateBaseline(baseline) + if (errors.length > 0) return errors + for (const name of ['fullScope', 'targeted']) { + const actual = report.groups[name]?.summary + if (actual?.runCount !== baseline.classification.sampleSizePerClass) { + errors.push( + `${name} report has ${actual?.runCount ?? 0} runs; ` + + `expected ${baseline.classification.sampleSizePerClass}` + ) + continue + } + const reference = baseline.reference[name].summary.durationSeconds + const budget = baseline.regressionBudget[name] + for (const [metric, percentField] of [ + ['median', 'medianPercent'], + ['p95', 'p95Percent'] + ]) { + const maximum = Math.ceil(reference[metric] * (1 + budget[percentField] / 100)) + if (actual.durationSeconds[metric] > maximum) { + errors.push( + `${name} duration ${metric} ${actual.durationSeconds[metric]}s exceeds ` + + `${maximum}s (${budget[percentField]}% over ${reference[metric]}s baseline)` + ) + } + } + } + return errors +} + +async function githubJson(url, token) { + const response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'User-Agent': 'ts-stack-ci-performance', + 'X-GitHub-Api-Version': '2022-11-28' + } + }) + if (!response.ok) { + throw new Error(`GitHub API ${response.status} for ${url}: ${await response.text()}`) + } + return await response.json() +} + +async function collectReport({ + repository, + workflow, + token, + sampleSize = DEFAULT_SAMPLE_SIZE, + minimumJobs = FULL_SCOPE_MINIMUM_JOBS +}) { + const apiRoot = `https://api.github.com/repos/${repository}` + const encodedWorkflow = encodeURIComponent(workflow) + const runData = await githubJson( + `${apiRoot}/actions/workflows/${encodedWorkflow}/runs?event=pull_request&status=success&per_page=${MAX_RUNS}`, + token + ) + const candidates = (runData.workflow_runs ?? []).filter( + run => run.status === 'completed' && run.conclusion === 'success' + ) + const groups = { fullScope: [], targeted: [] } + const batchSize = 8 + for (let index = 0; index < candidates.length; index += batchSize) { + const batch = candidates.slice(index, index + batchSize) + const measured = await Promise.all( + batch.map(async run => { + const data = await githubJson(`${run.jobs_url}?per_page=100`, token) + return measureRun(run, data.jobs ?? []) + }) + ) + for (const run of measured) { + const classification = classifyRun(run, minimumJobs) + if (groups[classification].length < sampleSize) groups[classification].push(run) + } + if (Object.values(groups).every(runs => runs.length === sampleSize)) break + } + for (const [name, runs] of Object.entries(groups)) { + if (runs.length !== sampleSize) { + throw new Error( + `Only ${runs.length} ${name} successful PR runs were available; expected ${sampleSize}` + ) + } + } + return createReport({ + repository, + workflow, + collectedAt: new Date().toISOString(), + sampleSize, + minimumJobs, + groups + }) +} + +function renderSummary(report, comparisons) { + const lines = [ + '## CI performance trend', + '', + '| Scope | Runs | Duration median | Duration p95 | Queue median | Prepare median | Artifact transfer median |', + '|---|---:|---:|---:|---:|---:|---:|' + ] + for (const [name, group] of Object.entries(report.groups)) { + const summary = group.summary + const seconds = value => (value === null ? 'unavailable' : `${value}s`) + lines.push( + `| ${name} | ${summary.runCount} | ${seconds(summary.durationSeconds.median)} | ` + + `${seconds(summary.durationSeconds.p95)} | ${seconds(summary.queueSeconds.median)} | ` + + `${seconds(summary.prepareSeconds.median)} | ` + + `${seconds(summary.artifactTransferSeconds.median)} |` + ) + } + lines.push( + '', + comparisons.length === 0 + ? 'Performance budget: passed.' + : `Performance budget: failed.\n\n${comparisons.map(error => `- ${error}`).join('\n')}`, + '', + 'GitHub-hosted CPU, memory, and action-internal cache-hit data are not exposed by the Actions REST API and remain an explicit instrumentation gap.' + ) + return `${lines.join('\n')}\n` +} + +function option(arguments_, name) { + const index = arguments_.indexOf(name) + return index === -1 ? undefined : arguments_[index + 1] +} + +async function main(arguments_) { + const baselinePath = option(arguments_, '--baseline') + const outputPath = option(arguments_, '--output') + const writeBaselinePath = option(arguments_, '--write-baseline') + const workflow = option(arguments_, '--workflow') ?? 'ci.yml' + if (!arguments_.includes('--collect')) { + throw new Error( + 'Usage: ci-performance.mjs --collect [--baseline file] [--output file] ' + + '[--write-baseline file] [--workflow ci.yml]' + ) + } + const token = process.env.GITHUB_TOKEN + if (!token) throw new Error('GITHUB_TOKEN is required for --collect') + const report = await collectReport({ + repository: process.env.GITHUB_REPOSITORY ?? DEFAULT_REPOSITORY, + workflow, + token + }) + if (outputPath) await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`) + if (writeBaselinePath) { + await writeFile(writeBaselinePath, `${JSON.stringify(createBaseline(report), null, 2)}\n`) + } + let comparisons = [] + if (baselinePath) { + const baseline = JSON.parse(await readFile(baselinePath, 'utf8')) + comparisons = compareToBaseline(report, baseline) + } + const summary = renderSummary(report, comparisons) + process.stdout.write(summary) + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, summary) + } + if (comparisons.length > 0) throw new Error(comparisons.join('\n')) +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + await main(process.argv.slice(2)) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} diff --git a/scripts/ci-performance.test.mjs b/scripts/ci-performance.test.mjs new file mode 100644 index 000000000..06c1980ec --- /dev/null +++ b/scripts/ci-performance.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + classifyRun, + compareToBaseline, + createBaseline, + createReport, + measureRun, + percentile, + summarize, + validateBaseline +} from './ci-performance.mjs' + +function actionRun(id, jobCount, durationSeconds) { + const createdAt = '2026-07-01T00:00:00Z' + const updatedAt = new Date(Date.parse(createdAt) + durationSeconds * 1000).toISOString() + return { + id, + url: `https://github.com/bsv-blockchain/ts-stack/actions/runs/${id}`, + headSha: String(id).padStart(40, '0'), + createdAt, + updatedAt, + durationSeconds, + queueSeconds: 3, + prepareSeconds: 120, + artifactTransferSeconds: 20, + jobCount, + jobs: [] + } +} + +function report(durations, targetedDurations = durations) { + return createReport({ + repository: 'bsv-blockchain/ts-stack', + workflow: 'ci.yml', + collectedAt: '2026-07-30T00:00:00.000Z', + sampleSize: 20, + minimumJobs: 50, + groups: { + fullScope: durations.map((duration, index) => actionRun(index + 1, 55, duration)), + targeted: targetedDurations.map((duration, index) => actionRun(index + 101, 25, duration)) + } + }) +} + +test('timing summaries use nearest-rank percentiles and population variance', () => { + const values = Array.from({ length: 20 }, (_, index) => index + 1) + assert.equal(percentile(values, 0.5), 10) + assert.equal(percentile(values, 0.95), 19) + assert.deepEqual(summarize(values), { + samples: 20, + minimum: 1, + median: 10, + p95: 19, + maximum: 20, + standardDeviation: 6 + }) +}) + +test('run measurement retains queue, job, step, prepare, and artifact timings', () => { + const measured = measureRun( + { + id: 1, + html_url: 'https://example.test/runs/1', + head_sha: 'a'.repeat(40), + created_at: '2026-07-01T00:00:00Z', + run_started_at: '2026-07-01T00:00:01Z', + updated_at: '2026-07-01T00:02:00Z' + }, + [ + { + name: 'Prepare / affected scope and audited build', + conclusion: 'success', + started_at: '2026-07-01T00:00:03Z', + completed_at: '2026-07-01T00:01:03Z', + steps: [ + { + name: 'Upload build artifact', + conclusion: 'success', + started_at: '2026-07-01T00:00:50Z', + completed_at: '2026-07-01T00:01:00Z' + } + ] + } + ] + ) + assert.equal(measured.durationSeconds, 120) + assert.equal(measured.queueSeconds, 3) + assert.equal(measured.prepareSeconds, 60) + assert.equal(measured.artifactTransferSeconds, 10) + assert.equal(measured.jobCount, 1) + assert.equal(measured.declaredJobCount, 1) + assert.equal(classifyRun({ jobCount: 50 }), 'fullScope') + assert.equal(classifyRun({ jobCount: 49 }), 'targeted') +}) + +test('baseline comparison permits bounded noise and rejects median or p95 regressions', () => { + const durations = Array.from({ length: 20 }, (_, index) => 500 + index) + const baseline = createBaseline(report(durations)) + assert.deepEqual(validateBaseline(baseline), []) + assert.deepEqual(compareToBaseline(report(durations), baseline), []) + + const regression = durations.map(duration => Math.round(duration * 1.3)) + assert.match(compareToBaseline(report(regression), baseline).join('\n'), /exceeds/) + + const incomplete = structuredClone(baseline) + incomplete.reference.fullScope.runs.pop() + assert.match(validateBaseline(incomplete).join('\n'), /must retain 20 run samples/) + + const duplicate = structuredClone(baseline) + duplicate.reference.targeted.runs[0] = duplicate.reference.fullScope.runs[0] + assert.match( + validateBaseline(duplicate).join('\n'), + /wrong job-count class|run ids must be unique/ + ) + + const staleSummary = structuredClone(baseline) + staleSummary.reference.fullScope.runs[0].durationSeconds += 1000 + assert.match(validateBaseline(staleSummary).join('\n'), /summary must match/) +}) diff --git a/scripts/generate-stack-facts.mjs b/scripts/generate-stack-facts.mjs index 4d17a52ca..35a0d58a1 100644 --- a/scripts/generate-stack-facts.mjs +++ b/scripts/generate-stack-facts.mjs @@ -148,12 +148,22 @@ below are source-manifest versions; registry publication is a separate, explicit authorized release action. ${table( - ['Area', 'Package', 'Source version', 'Profile', 'Runtime targets', 'Node engine', 'Source'], + [ + 'Area', + 'Package', + 'Source version', + 'Project profile', + 'Consumer profiles', + 'Runtime targets', + 'Node engine', + 'Source' + ], publicPackages.map(project => [ project.area, `\`${project.name}\``, `\`${project.version}\``, project.profile, + project.consumerProfiles.join(', '), project.runtimeTargets.join(', '), `\`${project.engines.node}\``, repositoryLink(project) diff --git a/scripts/repository-health.mjs b/scripts/repository-health.mjs index b3ede3e2c..c4536a92c 100644 --- a/scripts/repository-health.mjs +++ b/scripts/repository-health.mjs @@ -22,6 +22,40 @@ const IGNORED_DIRECTORIES = new Set([ ]) const CRITICALITIES = new Set(['tier-0', 'tier-1', 'tier-2', 'tier-3']) const RELEASES = new Set(['none', 'npm-oidc']) +const CONSUMER_PROFILE_CONTRACTS = { + 'browser-bundler': { + requiredRuntimeTargets: ['browser'], + requiredScript: 'test:browser' + }, + 'browser-esm': { + requiredRuntimeTargets: ['browser'], + requiredScript: 'test:browser' + }, + cli: { + requiredRuntimeTargets: ['node'], + requiredScript: 'pack:check' + }, + 'node-cjs': { + requiredRuntimeTargets: ['node'], + requiredScript: 'pack:check' + }, + 'node-esm': { + requiredRuntimeTargets: ['node'], + requiredScript: 'pack:check' + }, + 'react-native-metro': { + requiredRuntimeTargets: ['react-native'], + requiredScript: 'test:mobile' + }, + 'umd-global': { + requiredRuntimeTargets: ['browser', 'umd'], + requiredScript: 'test:browser' + }, + 'wasm-worker': { + requiredRuntimeTargets: ['browser', 'wasm', 'worker'], + requiredScript: 'test:consumers' + } +} const EXCEPTION_CATEGORIES = new Set([ 'advisory', 'coverage', @@ -170,6 +204,40 @@ function validateDependencyAutomation(registry) { return errors } +function validateConsumerProfileDefinitions(registry) { + const definitions = registry?.consumerProfileDefinitions + const prefix = 'projects.json consumerProfileDefinitions' + if (!definitions || typeof definitions !== 'object' || Array.isArray(definitions)) { + return [`${prefix} must be an object`] + } + + const errors = [] + const expectedNames = Object.keys(CONSUMER_PROFILE_CONTRACTS).sort() + const actualNames = Object.keys(definitions).sort() + if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { + errors.push(`${prefix} must define exactly: ${expectedNames.join(', ')}`) + } + for (const name of expectedNames) { + const definition = definitions[name] + if (!definition || typeof definition !== 'object') continue + for (const field of ['description', 'verification']) { + if (!isNonEmptyString(definition[field])) { + errors.push(`${prefix}.${name}.${field} must be a non-empty string`) + } + } + if ( + JSON.stringify(definition.requiredRuntimeTargets) !== + JSON.stringify(CONSUMER_PROFILE_CONTRACTS[name].requiredRuntimeTargets) + ) { + errors.push( + `${prefix}.${name}.requiredRuntimeTargets must be ` + + JSON.stringify(CONSUMER_PROFILE_CONTRACTS[name].requiredRuntimeTargets) + ) + } + } + return errors +} + function isDeclarationDependency(value) { if (typeof value !== 'string' || !value.startsWith('@types/')) return false const parts = value.slice('@types/'.length).split('__') @@ -200,6 +268,33 @@ function validateDeclarationDependencies(project, prefix) { return errors } +function validateConsumerProfiles(project, prefix) { + const profiles = project.consumerProfiles + if (project.release !== 'npm-oidc') { + return profiles === undefined + ? [] + : [`${prefix} consumerProfiles are only valid for public packages`] + } + if (!Array.isArray(profiles) || profiles.length === 0) { + return [`${prefix} must have one or more consumerProfiles`] + } + + const errors = [] + for (const profile of profiles) { + if (!Object.hasOwn(CONSUMER_PROFILE_CONTRACTS, profile)) { + errors.push(`${prefix} has unknown consumer profile ${JSON.stringify(profile)}`) + } + } + for (const duplicate of duplicateValues(profiles)) { + errors.push(`${prefix} repeats consumer profile ${duplicate}`) + } + const canonical = [...profiles].sort((left, right) => left.localeCompare(right)) + if (JSON.stringify(profiles) !== JSON.stringify(canonical)) { + errors.push(`${prefix} consumerProfiles must use canonical lexical order`) + } + return errors +} + function validateProjectMetadata(project, registry) { const errors = [] const prefix = `projects.json entry ${project.path ?? ''}` @@ -225,7 +320,10 @@ function validateProjectMetadata(project, registry) { if (!RELEASES.has(project.release)) { errors.push(`${prefix} has invalid release ${JSON.stringify(project.release)}`) } - errors.push(...validateDeclarationDependencies(project, prefix)) + errors.push( + ...validateDeclarationDependencies(project, prefix), + ...validateConsumerProfiles(project, prefix) + ) return errors } @@ -255,6 +353,85 @@ function validateProjectManifest(project, actual) { if (!isPrivate && project.release !== 'npm-oidc') { errors.push(`${prefix} is public but release is not npm-oidc`) } + for (const consumerProfile of project.consumerProfiles ?? []) { + const contract = CONSUMER_PROFILE_CONTRACTS[consumerProfile] + if (!contract) continue + for (const target of contract.requiredRuntimeTargets) { + if (!project.runtimeTargets.includes(target)) { + errors.push( + `${prefix} consumer profile ${consumerProfile} requires runtime target ${target}` + ) + } + } + if (!isNonEmptyString(actual.manifest.scripts?.[contract.requiredScript])) { + errors.push( + `${prefix} consumer profile ${consumerProfile} requires script ${contract.requiredScript}` + ) + } + } + const packScript = actual.manifest.scripts?.['pack:check'] + const configuredPackModes = + typeof packScript === 'string' + ? (packScript.match(/(?:^|\s)--modes\s+([^\s]+)/)?.[1]?.split(',') ?? ['cjs', 'esm']) + : [] + for (const mode of ['cjs', 'esm']) { + if (project.consumerProfiles?.includes(`node-${mode}`) && !configuredPackModes.includes(mode)) { + errors.push( + `${prefix} consumer profile node-${mode} is not exercised by the pack:check modes` + ) + } + } + if ( + project.consumerProfiles?.includes('cli') && + (typeof packScript !== 'string' || !/(?:^|\s)--bin(?:\s|$)/.test(packScript)) + ) { + errors.push(`${prefix} consumer profile cli requires pack:check to exercise --bin`) + } + if (project.consumerProfiles?.includes('cli') && !actual.manifest.bin) { + errors.push(`${prefix} consumer profile cli requires a package bin entry`) + } + const browserScript = actual.manifest.scripts?.['test:browser'] + if ( + project.consumerProfiles?.some(profile => + ['browser-bundler', 'browser-esm'].includes(profile) + ) && + (typeof browserScript !== 'string' || + !/(?:check-browser-package|check-wallet-toolbox-platform)\.mjs/.test(browserScript)) + ) { + errors.push(`${prefix} browser consumer profiles require an exact-package browser checker`) + } + if (project.consumerProfiles?.includes('umd-global')) { + const budgetPath = path.join(path.dirname(actual.manifestPath), 'browser-budget.json') + if (!fs.existsSync(budgetPath)) { + errors.push(`${prefix} consumer profile umd-global requires browser-budget.json`) + } else { + const budget = readJson(budgetPath) + if ( + !budget.umd || + !isNonEmptyString(budget.umd.path) || + !isNonEmptyString(budget.umd.global) + ) { + errors.push(`${prefix} consumer profile umd-global requires an exact UMD budget contract`) + } + } + } + const mobileScript = actual.manifest.scripts?.['test:mobile'] + if ( + project.consumerProfiles?.includes('react-native-metro') && + (typeof mobileScript !== 'string' || + !/check-wallet-toolbox-platform\.mjs mobile/.test(mobileScript)) + ) { + errors.push(`${prefix} consumer profile react-native-metro requires the exact Metro checker`) + } + const consumerScript = actual.manifest.scripts?.['test:consumers'] + if ( + project.consumerProfiles?.includes('wasm-worker') && + (typeof consumerScript !== 'string' || + !consumerScript.includes('test-node-consumers.mjs') || + !consumerScript.includes('browser/test.mjs')) + ) { + errors.push(`${prefix} consumer profile wasm-worker requires Node and browser worker checks`) + } for (const dependency of project.declarationDependencies ?? []) { if (!Object.hasOwn(actual.manifest.dependencies ?? {}, dependency)) { errors.push(`${prefix} must publish declaration dependency ${dependency}`) @@ -292,7 +469,11 @@ export function validateProjectRegistry(registry, discovered) { if (!registry?.ownerDefinitions || typeof registry.ownerDefinitions !== 'object') { errors.push('projects.json ownerDefinitions must be an object') } - errors.push(...validateDependencyAutomation(registry), ...validateGeneratedArtifacts(registry)) + errors.push( + ...validateDependencyAutomation(registry), + ...validateConsumerProfileDefinitions(registry), + ...validateGeneratedArtifacts(registry) + ) if (!registry?.profiles || typeof registry.profiles !== 'object') { errors.push('projects.json profiles must be an object') } diff --git a/scripts/repository-health.test.mjs b/scripts/repository-health.test.mjs index a79a64bc8..17e68222d 100644 --- a/scripts/repository-health.test.mjs +++ b/scripts/repository-health.test.mjs @@ -14,6 +14,7 @@ import { validateExceptionRegistry, validateProjectRegistry } from './repository-health.mjs' +import { validateBaseline as validateCiPerformanceBaseline } from './ci-performance.mjs' const healthDirectory = path.join(REPOSITORY_ROOT, 'governance/repository-health') const projects = readJson(path.join(healthDirectory, 'projects.json')) @@ -51,6 +52,16 @@ test('current repository health controls and ratchet are internally consistent', assert.equal(result.findings.length, 0) }) +test('CI performance baseline retains representative full and targeted cohorts', () => { + const baseline = readJson(path.join(REPOSITORY_ROOT, 'governance/ci-performance-baseline.json')) + assert.deepEqual(validateCiPerformanceBaseline(baseline), []) + assert.deepEqual(baseline.observability.unavailableFromActionsApi, [ + 'hosted-runner CPU utilization', + 'hosted-runner memory utilization', + 'action-internal cache hit rate' + ]) +}) + test('published declaration dependencies are explicit and backed by runtime modules', () => { const governed = projects.projects.filter(project => project.declarationDependencies) assert.deepEqual( @@ -146,6 +157,62 @@ test('every public package declares supported runtime and canonical support meta } }) +test('every public package has canonical, machine-verified consumer profiles', () => { + const publicProjects = projects.projects.filter(project => project.release === 'npm-oidc') + assert.equal(publicProjects.length, 30) + assert.ok(publicProjects.every(project => project.consumerProfiles.length > 0)) + assert.deepEqual( + [...new Set(publicProjects.flatMap(project => project.consumerProfiles))].sort(), + [ + 'browser-bundler', + 'browser-esm', + 'cli', + 'node-cjs', + 'node-esm', + 'react-native-metro', + 'umd-global', + 'wasm-worker' + ] + ) + + const unsorted = structuredClone(projects) + unsorted.projects.find(project => project.name === '@bsv/sdk').consumerProfiles = [ + 'node-esm', + 'browser-esm' + ] + assert.match( + validateProjectRegistry(unsorted, discoverWorkspaceProjects()).join('\n'), + /consumerProfiles must use canonical lexical order/ + ) + + const unsupportedMode = structuredClone(discoverWorkspaceProjects()) + unsupportedMode.find(project => project.manifest.name === '@bsv/overlay-topics').manifest.scripts[ + 'pack:check' + ] = 'node check-package-artifact.mjs . --modes cjs' + assert.match( + validateProjectRegistry(projects, unsupportedMode).join('\n'), + /consumer profile node-esm is not exercised by the pack:check modes/ + ) + + const missingTarget = structuredClone(projects) + missingTarget.projects.find( + project => project.name === '@bsv/message-box-client' + ).runtimeTargets = ['browser', 'node'] + assert.match( + validateProjectRegistry(missingTarget, discoverWorkspaceProjects()).join('\n'), + /consumer profile umd-global requires runtime target umd/ + ) + + const sourceOnlyBrowser = structuredClone(discoverWorkspaceProjects()) + sourceOnlyBrowser.find(project => project.manifest.name === '@bsv/did').manifest.scripts[ + 'test:browser' + ] = 'node browser/source-only.mjs' + assert.match( + validateProjectRegistry(projects, sourceOnlyBrowser).join('\n'), + /browser consumer profiles require an exact-package browser checker/ + ) +}) + test('contract findings are deterministic and match their recorded baseline', () => { const discovered = discoverWorkspaceProjects() const findings = collectContractFindings(projects, discovered) From 5ec9965c11735477d8aa16c3986dc839be444b88 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 17:49:54 -0700 Subject: [PATCH 2/2] resolve Wave 35 static analysis findings --- packages/verifast/browser/test.mjs | 7 +- scripts/ci-performance.mjs | 126 ++++++++++++++-------- scripts/repository-health.mjs | 166 ++++++++++++++++++----------- 3 files changed, 187 insertions(+), 112 deletions(-) diff --git a/packages/verifast/browser/test.mjs b/packages/verifast/browser/test.mjs index cf00d19d1..8510462e2 100644 --- a/packages/verifast/browser/test.mjs +++ b/packages/verifast/browser/test.mjs @@ -119,6 +119,11 @@ function collectPageErrors(page) { return errors } +function errorMessage(error) { + if (error instanceof Error) return error.message + return typeof error === 'string' ? error : 'Unknown browser interception failure' +} + async function forceStreamingFallback(page) { const session = await page.createCDPSession() let interceptions = 0 @@ -151,7 +156,7 @@ async function forceStreamingFallback(page) { }) interceptions += 1 } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)) + failures.push(errorMessage(error)) } })() }) diff --git a/scripts/ci-performance.mjs b/scripts/ci-performance.mjs index 7f421036f..1502ec14b 100644 --- a/scripts/ci-performance.mjs +++ b/scripts/ci-performance.mjs @@ -189,9 +189,8 @@ export function createBaseline(report) { } } -export function validateBaseline(baseline) { +function validateBaselineSource(baseline) { const errors = [] - if (baseline?.schemaVersion !== 1) errors.push('baseline schemaVersion must be 1') if ( baseline?.source?.repository !== DEFAULT_REPOSITORY || baseline?.source?.workflow !== 'ci.yml' || @@ -200,56 +199,75 @@ export function validateBaseline(baseline) { ) { errors.push('baseline source must be successful ts-stack pull-request ci.yml runs') } + return errors +} + +function validateBaselineClassification(baseline) { + const errors = [] if (baseline?.classification?.sampleSizePerClass !== DEFAULT_SAMPLE_SIZE) { errors.push(`baseline sampleSizePerClass must be ${DEFAULT_SAMPLE_SIZE}`) } if (baseline?.classification?.fullScopeMinimumJobs !== FULL_SCOPE_MINIMUM_JOBS) { errors.push(`baseline fullScopeMinimumJobs must be ${FULL_SCOPE_MINIMUM_JOBS}`) } - for (const name of ['fullScope', 'targeted']) { - const reference = baseline?.reference?.[name] - if (reference?.runs?.length !== DEFAULT_SAMPLE_SIZE) { - errors.push(`baseline ${name} must retain ${DEFAULT_SAMPLE_SIZE} run samples`) - } - if (reference?.summary?.runCount !== DEFAULT_SAMPLE_SIZE) { - errors.push(`baseline ${name} summary runCount must be ${DEFAULT_SAMPLE_SIZE}`) - } - const runs = Array.isArray(reference?.runs) ? reference.runs : [] - const expectedFullScope = name === 'fullScope' - for (const run of runs) { - if ( - !Number.isSafeInteger(run?.id) || - run.id <= 0 || - run.url !== `https://github.com/${DEFAULT_REPOSITORY}/actions/runs/${run.id}` || - !/^[0-9a-f]{40}$/.test(run?.headSha ?? '') || - !Number.isFinite(Date.parse(run?.createdAt)) - ) { - errors.push(`baseline ${name} contains an invalid exact run reference`) - break - } - if (run.jobCount >= FULL_SCOPE_MINIMUM_JOBS !== expectedFullScope) { - errors.push(`baseline ${name} contains a run in the wrong job-count class`) - break - } - } - if ( - runs.length === DEFAULT_SAMPLE_SIZE && - JSON.stringify(reference.summary) !== JSON.stringify(groupSummary(runs)) - ) { - errors.push(`baseline ${name} summary must match its retained run samples`) + return errors +} + +function exactRunError(run, expectedFullScope) { + if ( + !Number.isSafeInteger(run?.id) || + run.id <= 0 || + run.url !== `https://github.com/${DEFAULT_REPOSITORY}/actions/runs/${run.id}` || + !/^[0-9a-f]{40}$/.test(run?.headSha ?? '') || + !Number.isFinite(Date.parse(run?.createdAt)) + ) { + return 'contains an invalid exact run reference' + } + if (run.jobCount >= FULL_SCOPE_MINIMUM_JOBS !== expectedFullScope) { + return 'contains a run in the wrong job-count class' + } + return undefined +} + +function validateBaselineReference(baseline, name) { + const errors = [] + const reference = baseline?.reference?.[name] + if (reference?.runs?.length !== DEFAULT_SAMPLE_SIZE) { + errors.push(`baseline ${name} must retain ${DEFAULT_SAMPLE_SIZE} run samples`) + } + if (reference?.summary?.runCount !== DEFAULT_SAMPLE_SIZE) { + errors.push(`baseline ${name} summary runCount must be ${DEFAULT_SAMPLE_SIZE}`) + } + const runs = Array.isArray(reference?.runs) ? reference.runs : [] + for (const run of runs) { + const runError = exactRunError(run, name === 'fullScope') + if (runError) { + errors.push(`baseline ${name} ${runError}`) + break } - for (const metric of ['median', 'p95']) { - if (!Number.isFinite(reference?.summary?.durationSeconds?.[metric])) { - errors.push(`baseline ${name} duration ${metric} must be finite`) - } + } + if ( + runs.length === DEFAULT_SAMPLE_SIZE && + JSON.stringify(reference.summary) !== JSON.stringify(groupSummary(runs)) + ) { + errors.push(`baseline ${name} summary must match its retained run samples`) + } + for (const metric of ['median', 'p95']) { + if (!Number.isFinite(reference?.summary?.durationSeconds?.[metric])) { + errors.push(`baseline ${name} duration ${metric} must be finite`) } - for (const metric of ['medianPercent', 'p95Percent']) { - const value = baseline?.regressionBudget?.[name]?.[metric] - if (!Number.isFinite(value) || value <= 0 || value > 100) { - errors.push(`baseline ${name} ${metric} must be between 1 and 100`) - } + } + for (const metric of ['medianPercent', 'p95Percent']) { + const value = baseline?.regressionBudget?.[name]?.[metric] + if (!Number.isFinite(value) || value <= 0 || value > 100) { + errors.push(`baseline ${name} ${metric} must be between 1 and 100`) } } + return errors +} + +function validateUniqueRuns(baseline) { + const errors = [] const allRuns = Object.values(baseline?.reference ?? {}).flatMap(reference => Array.isArray(reference?.runs) ? reference.runs : [] ) @@ -262,6 +280,17 @@ export function validateBaseline(baseline) { return errors } +export function validateBaseline(baseline) { + return [ + ...(baseline?.schemaVersion === 1 ? [] : ['baseline schemaVersion must be 1']), + ...validateBaselineSource(baseline), + ...validateBaselineClassification(baseline), + ...validateBaselineReference(baseline, 'fullScope'), + ...validateBaselineReference(baseline, 'targeted'), + ...validateUniqueRuns(baseline) + ] +} + export function compareToBaseline(report, baseline) { const errors = validateBaseline(baseline) if (errors.length > 0) return errors @@ -373,11 +402,14 @@ function renderSummary(report, comparisons) { `${seconds(summary.artifactTransferSeconds.median)} |` ) } - lines.push( - '', + const comparisonDetails = comparisons.map(error => `- ${error}`).join('\n') + const budgetStatus = comparisons.length === 0 ? 'Performance budget: passed.' - : `Performance budget: failed.\n\n${comparisons.map(error => `- ${error}`).join('\n')}`, + : ['Performance budget: failed.', '', comparisonDetails].join('\n') + lines.push( + '', + budgetStatus, '', 'GitHub-hosted CPU, memory, and action-internal cache-hit data are not exposed by the Actions REST API and remain an explicit instrumentation gap.' ) @@ -427,8 +459,8 @@ async function main(arguments_) { if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { try { await main(process.argv.slice(2)) - } catch (error) { - console.error(error.message) + } catch { + console.error('CI performance command failed.') process.exitCode = 1 } } diff --git a/scripts/repository-health.mjs b/scripts/repository-health.mjs index c4536a92c..acb89d3cf 100644 --- a/scripts/repository-health.mjs +++ b/scripts/repository-health.mjs @@ -70,6 +70,10 @@ const EXCEPTION_CATEGORIES = new Set([ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ +function compareText(left, right) { + return left.localeCompare(right) +} + function relativePath(filePath, root = REPOSITORY_ROOT) { const relative = path.relative(root, filePath).split(path.sep).join('/') return relative === '' ? '.' : relative @@ -212,8 +216,8 @@ function validateConsumerProfileDefinitions(registry) { } const errors = [] - const expectedNames = Object.keys(CONSUMER_PROFILE_CONTRACTS).sort() - const actualNames = Object.keys(definitions).sort() + const expectedNames = Object.keys(CONSUMER_PROFILE_CONTRACTS).sort(compareText) + const actualNames = Object.keys(definitions).sort(compareText) if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { errors.push(`${prefix} must define exactly: ${expectedNames.join(', ')}`) } @@ -335,24 +339,8 @@ function runtimePackageForTypes(dependency) { : `@${name.slice(0, scopedSeparator)}/${name.slice(scopedSeparator + 2)}` } -function validateProjectManifest(project, actual) { - const prefix = `projects.json entry ${project.path ?? ''}` - if (!actual) return [`${prefix} has no discovered workspace package.json`] - +function validateRequiredConsumerContracts(project, actual, prefix) { const errors = [] - if (actual.manifest.name !== project.name) { - errors.push( - `${prefix} name ${JSON.stringify(project.name)} does not match package.json ` + - `${JSON.stringify(actual.manifest.name)}` - ) - } - const isPrivate = actual.manifest.private === true - if (isPrivate && project.release !== 'none') { - errors.push(`${prefix} is private but release is ${project.release}`) - } - if (!isPrivate && project.release !== 'npm-oidc') { - errors.push(`${prefix} is public but release is not npm-oidc`) - } for (const consumerProfile of project.consumerProfiles ?? []) { const contract = CONSUMER_PROFILE_CONTRACTS[consumerProfile] if (!contract) continue @@ -369,69 +357,119 @@ function validateProjectManifest(project, actual) { ) } } - const packScript = actual.manifest.scripts?.['pack:check'] - const configuredPackModes = - typeof packScript === 'string' - ? (packScript.match(/(?:^|\s)--modes\s+([^\s]+)/)?.[1]?.split(',') ?? ['cjs', 'esm']) - : [] + return errors +} + +function configuredPackModes(packScript) { + if (typeof packScript !== 'string') return [] + const match = /(?:^|\s)--modes\s+([^\s]+)/.exec(packScript) + return match?.[1]?.split(',') ?? ['cjs', 'esm'] +} + +function validateNodeConsumerProfiles(profiles, packScript, prefix) { + const errors = [] + const packModes = configuredPackModes(packScript) for (const mode of ['cjs', 'esm']) { - if (project.consumerProfiles?.includes(`node-${mode}`) && !configuredPackModes.includes(mode)) { + if (profiles.has(`node-${mode}`) && !packModes.includes(mode)) { errors.push( `${prefix} consumer profile node-${mode} is not exercised by the pack:check modes` ) } } - if ( - project.consumerProfiles?.includes('cli') && - (typeof packScript !== 'string' || !/(?:^|\s)--bin(?:\s|$)/.test(packScript)) - ) { + return errors +} + +function validateCliConsumerProfile(profiles, packScript, manifest, prefix) { + if (!profiles.has('cli')) return [] + const errors = [] + if (typeof packScript !== 'string' || !/(?:^|\s)--bin(?:\s|$)/.test(packScript)) { errors.push(`${prefix} consumer profile cli requires pack:check to exercise --bin`) } - if (project.consumerProfiles?.includes('cli') && !actual.manifest.bin) { - errors.push(`${prefix} consumer profile cli requires a package bin entry`) - } - const browserScript = actual.manifest.scripts?.['test:browser'] + if (!manifest.bin) errors.push(`${prefix} consumer profile cli requires a package bin entry`) + return errors +} + +function validateBrowserConsumerProfiles(profiles, browserScript, prefix) { + if (!profiles.has('browser-bundler') && !profiles.has('browser-esm')) return [] if ( - project.consumerProfiles?.some(profile => - ['browser-bundler', 'browser-esm'].includes(profile) - ) && - (typeof browserScript !== 'string' || - !/(?:check-browser-package|check-wallet-toolbox-platform)\.mjs/.test(browserScript)) + typeof browserScript === 'string' && + /(?:check-browser-package|check-wallet-toolbox-platform)\.mjs/.test(browserScript) ) { - errors.push(`${prefix} browser consumer profiles require an exact-package browser checker`) + return [] } - if (project.consumerProfiles?.includes('umd-global')) { - const budgetPath = path.join(path.dirname(actual.manifestPath), 'browser-budget.json') - if (!fs.existsSync(budgetPath)) { - errors.push(`${prefix} consumer profile umd-global requires browser-budget.json`) - } else { - const budget = readJson(budgetPath) - if ( - !budget.umd || - !isNonEmptyString(budget.umd.path) || - !isNonEmptyString(budget.umd.global) - ) { - errors.push(`${prefix} consumer profile umd-global requires an exact UMD budget contract`) - } - } + return [`${prefix} browser consumer profiles require an exact-package browser checker`] +} + +function validateUmdConsumerProfile(profiles, manifestPath, prefix) { + if (!profiles.has('umd-global')) return [] + const budgetPath = path.join(path.dirname(manifestPath), 'browser-budget.json') + if (!fs.existsSync(budgetPath)) { + return [`${prefix} consumer profile umd-global requires browser-budget.json`] } - const mobileScript = actual.manifest.scripts?.['test:mobile'] + const budget = readJson(budgetPath) + if (!budget.umd || !isNonEmptyString(budget.umd.path) || !isNonEmptyString(budget.umd.global)) { + return [`${prefix} consumer profile umd-global requires an exact UMD budget contract`] + } + return [] +} + +function validateMobileConsumerProfile(profiles, mobileScript, prefix) { + if (!profiles.has('react-native-metro')) return [] if ( - project.consumerProfiles?.includes('react-native-metro') && - (typeof mobileScript !== 'string' || - !/check-wallet-toolbox-platform\.mjs mobile/.test(mobileScript)) + typeof mobileScript === 'string' && + /check-wallet-toolbox-platform\.mjs mobile/.test(mobileScript) ) { - errors.push(`${prefix} consumer profile react-native-metro requires the exact Metro checker`) + return [] } - const consumerScript = actual.manifest.scripts?.['test:consumers'] + return [`${prefix} consumer profile react-native-metro requires the exact Metro checker`] +} + +function validateWasmWorkerConsumerProfile(profiles, consumerScript, prefix) { + if (!profiles.has('wasm-worker')) return [] if ( - project.consumerProfiles?.includes('wasm-worker') && - (typeof consumerScript !== 'string' || - !consumerScript.includes('test-node-consumers.mjs') || - !consumerScript.includes('browser/test.mjs')) + typeof consumerScript === 'string' && + consumerScript.includes('test-node-consumers.mjs') && + consumerScript.includes('browser/test.mjs') ) { - errors.push(`${prefix} consumer profile wasm-worker requires Node and browser worker checks`) + return [] + } + return [`${prefix} consumer profile wasm-worker requires Node and browser worker checks`] +} + +function validateConsumerProfileContracts(project, actual, prefix) { + const profiles = new Set(project.consumerProfiles ?? []) + const scripts = actual.manifest.scripts ?? {} + const packScript = scripts['pack:check'] + return [ + ...validateRequiredConsumerContracts(project, actual, prefix), + ...validateNodeConsumerProfiles(profiles, packScript, prefix), + ...validateCliConsumerProfile(profiles, packScript, actual.manifest, prefix), + ...validateBrowserConsumerProfiles(profiles, scripts['test:browser'], prefix), + ...validateUmdConsumerProfile(profiles, actual.manifestPath, prefix), + ...validateMobileConsumerProfile(profiles, scripts['test:mobile'], prefix), + ...validateWasmWorkerConsumerProfile(profiles, scripts['test:consumers'], prefix) + ] +} + +function validateProjectManifest(project, actual) { + const prefix = `projects.json entry ${project.path ?? ''}` + if (!actual) return [`${prefix} has no discovered workspace package.json`] + + const errors = [] + if (actual.manifest.name !== project.name) { + errors.push( + `${prefix} name ${JSON.stringify(project.name)} does not match package.json ` + + `${JSON.stringify(actual.manifest.name)}` + ) + } + const isPrivate = actual.manifest.private === true + if (isPrivate && project.release !== 'none') { + errors.push(`${prefix} is private but release is ${project.release}`) + } + if (!isPrivate && project.release !== 'npm-oidc') { + errors.push(`${prefix} is public but release is not npm-oidc`) } + errors.push(...validateConsumerProfileContracts(project, actual, prefix)) for (const dependency of project.declarationDependencies ?? []) { if (!Object.hasOwn(actual.manifest.dependencies ?? {}, dependency)) { errors.push(`${prefix} must publish declaration dependency ${dependency}`)