From ae03b0dc4ffcee8222366294ca3c7b085e80f90d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 22 Sep 2026 18:03:12 -0700 Subject: [PATCH 1/3] fix(ci): require successful selected lanes on main and full acceptance --- .github/workflows/ci.yml | 74 ++++++++---- docs/reference/ci-performance.md | 34 ++++-- docs/reference/package-api-migrations.md | 6 +- governance/package-release-notes.json | 4 +- packages/helpers/simple/README.md | 5 + .../core/__tests__/code-unit-order.test.ts | 24 ++++ .../simple/src/core/certificate-validation.ts | 8 +- .../simple/src/core/code-unit-order.ts | 10 ++ .../simple/src/modules/certificate-service.ts | 8 +- .../helpers/simple/src/modules/credentials.ts | 8 +- .../src/modules/file-revocation-store.ts | 8 +- .../src/server/credential-issuer-handler.ts | 8 +- .../overlay-tools/__tests/Historian.test.ts | 8 ++ scripts/ci-affected-scope.mjs | 3 + scripts/ci-affected-scope.test.mjs | 12 +- scripts/ci-orchestration.test.mjs | 40 ++++++- scripts/ci-result-gate.mjs | 73 ++++++++++++ scripts/ci-result-gate.test.mjs | 110 ++++++++++++++++++ 18 files changed, 365 insertions(+), 78 deletions(-) create mode 100644 packages/helpers/simple/src/core/__tests__/code-unit-order.test.ts create mode 100644 packages/helpers/simple/src/core/code-unit-order.ts create mode 100644 scripts/ci-result-gate.mjs create mode 100644 scripts/ci-result-gate.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aa79fb5d..9f3ea15bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [main] pull_request: branches: [main] + # Release acceptance must be able to exercise the complete current tree. + workflow_dispatch: permissions: {} @@ -434,7 +436,7 @@ jobs: mutation-tests: name: Mutation / ${{ matrix.target }} - if: needs.prepare.outputs.mutation-targets != '[]' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.mutation-targets != '[]' needs: prepare runs-on: ubuntu-latest # The governed air-gap codec target currently instruments 352 mutants and @@ -443,7 +445,7 @@ jobs: permissions: contents: read strategy: - fail-fast: true + fail-fast: false max-parallel: 6 matrix: target: ${{ fromJSON(needs.prepare.outputs.mutation-targets) }} @@ -496,7 +498,7 @@ jobs: standard-tests: name: Tests / non-coverage packages - if: needs.prepare.outputs.standard-packages != '[]' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.standard-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 25 @@ -539,14 +541,14 @@ jobs: dependent-tests: name: Tests / affected dependents (${{ matrix.shard }}/${{ matrix.total }}) - if: needs.prepare.outputs.dependent-test-packages != '[]' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.dependent-test-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read strategy: - fail-fast: true + fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.dependent-test-matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -596,21 +598,21 @@ jobs: filters+=(--filter "$package") done if [ "${#filters[@]}" -gt 0 ]; then - pnpm -r --no-sort "${filters[@]}" exec node \ + pnpm -r --workspace-concurrency=1 --no-sort "${filters[@]}" exec node \ "$GITHUB_WORKSPACE/scripts/run-prebuilt-package-script.mjs" \ --script test fi browser-packages: name: Platform / browser packages (${{ matrix.shard }}/${{ matrix.total }}) - if: needs.prepare.outputs.browser-packages != '[]' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.browser-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 25 permissions: contents: read strategy: - fail-fast: true + fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.browser-matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -667,7 +669,7 @@ jobs: wallet-browser-platform: name: Platform / wallet browser - if: needs.prepare.outputs.wallet_client == 'true' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet_client == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 20 @@ -701,7 +703,7 @@ jobs: wallet-mobile-platform: name: Platform / wallet mobile - if: needs.prepare.outputs.wallet_mobile == 'true' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet_mobile == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 20 @@ -740,7 +742,7 @@ jobs: coverage-sdk: name: Coverage / SDK - if: needs.prepare.outputs.sdk == 'true' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.sdk == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 @@ -791,7 +793,7 @@ jobs: coverage-did: name: Coverage / DID - if: needs.prepare.outputs.did == 'true' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.did == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 20 @@ -826,14 +828,14 @@ jobs: coverage-wallet: name: Coverage / wallet-toolbox (${{ matrix.shard }}/4) - if: needs.prepare.outputs.wallet == 'true' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 40 permissions: contents: read strategy: - fail-fast: true + fail-fast: false matrix: shard: [1, 2, 3, 4] steps: @@ -875,7 +877,7 @@ jobs: coverage-wallet-monitor: name: Coverage / wallet-toolbox monitor - if: needs.prepare.outputs.wallet == 'true' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 25 @@ -920,7 +922,7 @@ jobs: coverage-verifast: name: Coverage / VeriFast - if: needs.prepare.outputs.verifast == 'true' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.verifast == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 @@ -972,14 +974,14 @@ jobs: coverage-other: name: Coverage / other affected packages (${{ matrix.shard }}/${{ matrix.total }}) - if: needs.prepare.outputs.coverage-other-packages != '[]' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.coverage-other-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 35 permissions: contents: read strategy: - fail-fast: true + fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.coverage-other-matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1025,7 +1027,7 @@ jobs: "import { MongoMemoryServer } from 'mongodb-memory-server'; const s = await MongoMemoryServer.create({ instance: { launchTimeout: 120000 } }); await s.stop(); console.log('mongodb-memory-server binary cache warmed');" fi if [ "${#filters[@]}" -gt 0 ]; then - pnpm -r --no-sort "${filters[@]}" exec node \ + pnpm -r --workspace-concurrency=1 --no-sort "${filters[@]}" exec node \ "$GITHUB_WORKSPACE/scripts/run-prebuilt-package-script.mjs" \ --script test:coverage else @@ -1229,14 +1231,14 @@ jobs: infra: name: Infra / ${{ matrix.display }} - if: needs.infra-scope.outputs.has-infra == 'true' + if: always() && needs.infra-scope.result == 'success' && needs.infra-scope.outputs.has-infra == 'true' needs: infra-scope runs-on: ubuntu-latest timeout-minutes: 35 permissions: contents: read strategy: - fail-fast: true + fail-fast: false matrix: ${{ fromJson(needs.infra-scope.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1434,6 +1436,22 @@ jobs: name: merge-gate if: always() needs: + - scope + - prepare + - infra-scope + - standard-tests + - dependent-tests + - browser-packages + - wallet-browser-platform + - wallet-mobile-platform + - coverage-sdk + - coverage-did + - coverage-wallet + - coverage-wallet-monitor + - coverage-verifast + - coverage-other + - coverage-upload + - mutation-tests - repository-health - sonar-zero-findings - build-and-test @@ -1444,8 +1462,20 @@ jobs: - dependency-review runs-on: ubuntu-latest timeout-minutes: 5 - permissions: {} + permissions: + 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: Reject missing or skipped selected work + env: + CI_NEEDS: ${{ toJSON(needs) }} + CI_EVENT: ${{ github.event_name }} + run: node scripts/ci-result-gate.mjs - name: Verify every required CI result env: HEALTH_RESULT: ${{ needs.repository-health.result }} diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index 373f87ade..aabf6fc38 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -2,9 +2,9 @@ id: ci-performance title: 'CI Performance Governance' kind: reference -version: '1.1.0' -last_updated: '2026-07-31' -last_verified: '2026-08-30' +version: '1.2.0' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 status: stable tags: [reference, ci, performance, github-actions] @@ -31,14 +31,14 @@ prerequisite. This preserves behavioral coverage of possible consumers without paying to regenerate unchanged packages' coverage reports. A lockfile-only change selects the importers whose lock snapshots actually changed instead of treating the root lockfile as a global invalidation. Root -compiler and workspace controls still select the complete graph deliberately. +compiler, workspace and shared CI execution controls select the complete graph deliberately. Mutation selection follows each target's exact implementation, property, regression, configuration, and policy inputs. Package-wide mutation suites expand only where their configuration really covers the whole package. Image -jobs follow changed build contexts: a CI-workflow-only change selects no -application image, while shared image/runtime contract inputs deliberately fan -out to the registered consumers. +jobs follow changed build contexts. Changes to the shared CI workflow validate +all infrastructure build lanes; unrelated documentation workflow changes do not. +Shared image/runtime contract inputs fan out to the registered consumers. The main CI workflow builds the selected graph once and shares immutable outputs with isolated test lanes, skips empty lanes, installs through the @@ -46,8 +46,10 @@ setup-node pnpm cache, caches the immutable MongoDB test binary, and rebuilds native/build tools only in jobs that execute them. Browser lanes retain exact package-composition reports without rebuilding the workspace. The cheap repository-health, scope, Sonar, and dependency-review gates complete before -dependency installation, and all expensive matrices cancel unfinished siblings -after the first failure. +dependency installation. Matrix siblings finish after a failure so acceptance +evidence includes every selected shard. Within a regression or coverage shard, +packages execute serially because individual test runners already use worker +pools; this prevents nested pools starving real-cryptography integration tests. These controls reduce repeated CPU, network, and setup work without weakening the tests selected by the dependency or registered trust-boundary graph. @@ -71,3 +73,17 @@ GITHUB_TOKEN=... node scripts/ci-performance.mjs \ 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. + +## Complete release acceptance + +Dispatch `CI` manually on the reviewed main commit to select the entire governed +workspace, all infrastructure build lanes and mutation targets. This deliberately +uses the workflow's all-scope path instead of comparing only the latest commit. +Source merges still validate their affected scope automatically. + +Every execution lane uses an explicit successful-preparation condition that +survives intentionally skipped PR-only gates on a main push. The final result +gate independently checks scope outputs against each job result: selected jobs +must succeed; missing, cancelled or skipped selected jobs fail the merge gate. +Only genuinely unselected lanes and main's PR-only checks may be skipped. +Do not infer full release acceptance from a green documentation-only push. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index cb1137120..78efaf09b 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -49,7 +49,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/paymail` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/messaging/paymail.md) | Valid public APIs and deployed Paymail wire shapes remain supported; malformed, ambiguous, oversized, local-network, wrong-owner, mutation-backed, or request-mismatched input now fails closed. Production origins and capability endpoints must be credential-free public HTTPS, except exact localhost development; custom transports and resolver configuration must use the documented exact runtime types. Receive-route verifySignature defaults to false for legacy compatibility, so unsigned metadata must not authorize a sender. Even when enabled, the legacy P2P signature authenticates only the transaction ID, not recipient, reference, sender-handle context, endpoint, freshness, or replay state. The historical 6745385c3fc0 advertisement is this package's compatibility behavior and is not the upstream signed/timestamped Basic Address Resolution assurance; a complete correction needs a versioned wire migration. Transaction Negotiation v1 is unauthenticated public input. Handlers must validate outputs, references, thread/freshness policy, proofs, callbacks, and durable replay state before financial or authorization effects. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/payment-express-middleware` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No wire or public API migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Wallet adapters must return accepted and optional isMerge as own data properties; inherited/accessor-backed verdicts now fail closed. Overinclusive Atomic BEEF receipts are normalized to the declared subject closure. Production replicas must share one durable atomic replay store, and operators must reconcile a replay-store failure after wallet acceptance before asking a payer to spend again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/sdk` | `2.7.1` | `2.8.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. | -| `@bsv/simple` | `0.5.3` | `0.6.0` | minor | [API and usage](../packages/helpers/simple.md) | Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/simple` | `0.5.3` | `0.6.0` | minor | [API and usage](../packages/helpers/simple.md) | Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. The internal comparator consolidation requires no migration and is included in the existing unpublished 0.6.0 candidate. | | `@bsv/templates` | `1.9.1` | `1.10.2` | minor | [API and usage](../packages/helpers/templates.md) | No API migration is required for valid templates. MandalaToken now rejects locking or decoding amounts outside JavaScript's positive safe-integer range; audit any previously accepted non-exact amount scripts before replay. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.6` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/verifast` | `0.3.5` | `0.3.6` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | @@ -444,8 +444,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/simple.md](../packages/helpers/simple.md) - Source: [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) -- Release note: Corrects PushDrop self-derivation ownership, pins Message Box responses to the authenticated peer, hardens wallet, credential, DID, persistence, and transaction boundaries, preserves explicit offline migration identifiers and historical signature verification for pre-0.6 short certificate types while keeping new issuance, remote metadata, and wallet operations canonical 32-byte, and changes the generated server-wallet handler to require explicit authenticated action-level authorization. -- Migration: Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Corrects PushDrop self-derivation ownership, pins Message Box responses to the authenticated peer, hardens wallet, credential, DID, persistence, and transaction boundaries, preserves explicit offline migration identifiers and historical signature verification for pre-0.6 short certificate types while keeping new issuance, remote metadata, and wallet operations canonical 32-byte, and changes the generated server-wallet handler to require explicit authenticated action-level authorization. Shares one tested UTF-16 code-unit comparator across certificate signing and persisted field ordering, preserving historical ordering independently of locale. +- Migration: Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. The internal comparator consolidation requires no migration and is included in the existing unpublished 0.6.0 candidate. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | -------------------------------------------- | ------------------------------------------------ | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index afc518636..a8078b246 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -175,8 +175,8 @@ "name": "@bsv/simple", "publishedVersion": "0.5.3", "releaseType": "minor", - "summary": "Corrects PushDrop self-derivation ownership, pins Message Box responses to the authenticated peer, hardens wallet, credential, DID, persistence, and transaction boundaries, preserves explicit offline migration identifiers and historical signature verification for pre-0.6 short certificate types while keeping new issuance, remote metadata, and wallet operations canonical 32-byte, and changes the generated server-wallet handler to require explicit authenticated action-level authorization.", - "migration": "Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/." + "summary": "Corrects PushDrop self-derivation ownership, pins Message Box responses to the authenticated peer, hardens wallet, credential, DID, persistence, and transaction boundaries, preserves explicit offline migration identifiers and historical signature verification for pre-0.6 short certificate types while keeping new issuance, remote metadata, and wallet operations canonical 32-byte, and changes the generated server-wallet handler to require explicit authenticated action-level authorization. Shares one tested UTF-16 code-unit comparator across certificate signing and persisted field ordering, preserving historical ordering independently of locale.", + "migration": "Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. The internal comparator consolidation requires no migration and is included in the existing unpublished 0.6.0 candidate." }, { "name": "@bsv/templates", diff --git a/packages/helpers/simple/README.md b/packages/helpers/simple/README.md index 4d717fa81..0c7ccc2f8 100644 --- a/packages/helpers/simple/README.md +++ b/packages/helpers/simple/README.md @@ -62,6 +62,11 @@ certifier, subject, and type before changing wallet state. The optional `fetch` override is an explicitly trusted escape hatch for controlled tests or local development; applications must provide equivalent origin and network controls. +Certificate and persisted credential field names retain their historical UTF-16 +code-unit order, independent of the host locale. The shared internal comparator +in the unpublished 0.6.0 candidate does not change signed bytes or require a +consumer migration. + `CredentialIssuer.verify()` authenticates the embedded BSV certificate and requires the W3C wrapper's issuer, subject, type, fields, proof, and revocation reference to match it. Wrapper timestamps are formatting metadata, not claims diff --git a/packages/helpers/simple/src/core/__tests__/code-unit-order.test.ts b/packages/helpers/simple/src/core/__tests__/code-unit-order.test.ts new file mode 100644 index 000000000..ab252b216 --- /dev/null +++ b/packages/helpers/simple/src/core/__tests__/code-unit-order.test.ts @@ -0,0 +1,24 @@ +import { compareCodeUnits } from '../code-unit-order' + +const canonical = ['', '10', '2', 'A', 'Z', 'a', 'z', 'ä', '\ud800', '😀', '\ue000'] + +describe('canonical certificate field order', () => { + it('preserves historical code-unit order across reversed and duplicate field lists', () => { + expect([...canonical].reverse().sort(compareCodeUnits)).toEqual(canonical) + expect([...canonical, ...canonical].sort(compareCodeUnits)).toEqual( + canonical.flatMap(key => [key, key]) + ) + }) + + it('is reflexive, antisymmetric and transitive for every canonical pair', () => { + for (let left = 0; left < canonical.length; left++) { + for (let right = 0; right < canonical.length; right++) { + expect(compareCodeUnits(canonical[left], canonical[right])).toBe(Math.sign(left - right)) + for (let last = right; last < canonical.length; last++) { + if (left <= right) + expect(compareCodeUnits(canonical[left], canonical[last])).toBeLessThanOrEqual(0) + } + } + } + }) +}) diff --git a/packages/helpers/simple/src/core/certificate-validation.ts b/packages/helpers/simple/src/core/certificate-validation.ts index d42c91c07..a8fd2a9cd 100644 --- a/packages/helpers/simple/src/core/certificate-validation.ts +++ b/packages/helpers/simple/src/core/certificate-validation.ts @@ -1,3 +1,4 @@ +import { compareCodeUnits } from './code-unit-order' import { createPublicHTTPSFetch, MasterCertificate, @@ -196,13 +197,6 @@ function snapshotLegacyCertificateTypes(value: unknown): string[] { return [...new Set(types)] } -/** UTF-16 code-unit order, the same order as a comparator-less `Array#sort`. */ -function compareCodeUnits(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - function historicalCertificatePreimage(certificate: CertificateData): number[] { const writer = new Writer() writer.write(toArray(certificate.type, 'base64')) diff --git a/packages/helpers/simple/src/core/code-unit-order.ts b/packages/helpers/simple/src/core/code-unit-order.ts new file mode 100644 index 000000000..1a403187b --- /dev/null +++ b/packages/helpers/simple/src/core/code-unit-order.ts @@ -0,0 +1,10 @@ +/** + * Order canonical field names by UTF-16 code units, matching the historical + * default Array.sort order. Locale collation would change signed field order. + * Kept internal to Simple's certificate and persistence implementations. + */ +export function compareCodeUnits(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} diff --git a/packages/helpers/simple/src/modules/certificate-service.ts b/packages/helpers/simple/src/modules/certificate-service.ts index 1cfc17923..571fbad05 100644 --- a/packages/helpers/simple/src/modules/certificate-service.ts +++ b/packages/helpers/simple/src/modules/certificate-service.ts @@ -1,3 +1,4 @@ +import { compareCodeUnits } from '../core/code-unit-order' import { stringifyBRC100 } from '@bsv/sdk' import { toArray, toBase64 } from '@bsv/sdk/primitives/utils' import { @@ -37,13 +38,6 @@ function canonicalBase64Identifier(value: unknown, name: string): string { return value } -/** UTF-16 code-unit order, the same order as a comparator-less `Array#sort`. */ -function compareCodeUnits(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - function sameRecord(left: Record, right: unknown): boolean { const record = snapshotPlainDataRecord(right) if (record == null) return false diff --git a/packages/helpers/simple/src/modules/credentials.ts b/packages/helpers/simple/src/modules/credentials.ts index 377c061f6..1ed06bb8d 100644 --- a/packages/helpers/simple/src/modules/credentials.ts +++ b/packages/helpers/simple/src/modules/credentials.ts @@ -1,3 +1,4 @@ +import { compareCodeUnits } from '../core/code-unit-order' import { ProtoWallet, PrivateKey, @@ -252,13 +253,6 @@ function canonicalIsoDate(value: unknown): string | undefined { return value } -/** UTF-16 code-unit order, the same order as a comparator-less `Array#sort`. */ -function compareCodeUnits(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - function credentialSubjectMatches(value: unknown, certificate: CertificateData): boolean { const record = snapshotPlainDataRecord(value) if (record == null || record.id !== `did:bsv:${certificate.subject}`) return false diff --git a/packages/helpers/simple/src/modules/file-revocation-store.ts b/packages/helpers/simple/src/modules/file-revocation-store.ts index 9ba06a32e..a9ca8ceab 100644 --- a/packages/helpers/simple/src/modules/file-revocation-store.ts +++ b/packages/helpers/simple/src/modules/file-revocation-store.ts @@ -1,3 +1,4 @@ +import { compareCodeUnits } from '../core/code-unit-order' import { RevocationRecord, RevocationStore } from '../core/types' import * as nodePath from 'node:path' import { JsonFileStore } from '../server/json-file-store' @@ -32,13 +33,6 @@ function ownBytes(value: unknown): number[] { return output } -/** UTF-16 code-unit order, the same order as a comparator-less `Array#sort`. */ -function compareCodeUnits(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - function ownRecord(value: unknown): RevocationRecord { if (value == null || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Stored revocation record is invalid') diff --git a/packages/helpers/simple/src/server/credential-issuer-handler.ts b/packages/helpers/simple/src/server/credential-issuer-handler.ts index 8189a2f4a..e022a1249 100644 --- a/packages/helpers/simple/src/server/credential-issuer-handler.ts +++ b/packages/helpers/simple/src/server/credential-issuer-handler.ts @@ -8,6 +8,7 @@ * createCredentialIssuerHandler() returns Next.js App Router compatible { GET, POST }. */ +import { compareCodeUnits } from '../core/code-unit-order' import { join } from 'node:path' import { PrivateKey } from '@bsv/sdk' import { toArray, toBase64 } from '@bsv/sdk/primitives/utils' @@ -48,13 +49,6 @@ function validatedIssuerPrivateKey(value: unknown): string { return value } -/** UTF-16 code-unit order, the same order as a comparator-less `Array#sort`. */ -function compareCodeUnits(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - function validateStoredIssuerKey(value: unknown): { privateKey: string; publicKey: string } { const record = snapshotPlainDataRecord(value) if (record == null) throw new Error('Stored credential issuer key is invalid') diff --git a/packages/sdk/src/overlay-tools/__tests/Historian.test.ts b/packages/sdk/src/overlay-tools/__tests/Historian.test.ts index d8c72728c..d177aaf57 100644 --- a/packages/sdk/src/overlay-tools/__tests/Historian.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/Historian.test.ts @@ -935,6 +935,14 @@ describe('Historian', () => { } finally { Array.prototype.sort = originalSort } + const firstKey = [...cache.keys()][0] + const ascendingContext = Object.fromEntries(orderedKeys.map(key => [key, context[key]])) + await makeCachingHistorian({ historyCache: cache }).buildHistory( + cacheableTransaction(), + ascendingContext + ) + // Both insertion orders must resolve to the identical persisted cache namespace. + expect([...cache.keys()]).toEqual([firstKey]) expect([...cache.keys()].join('\n')).toContain(encodedFields) expect(encodedFields.indexOf('s1:A=')).toBeLessThan(encodedFields.indexOf('s1:z=')) expect(encodedFields.indexOf('s1:z=')).toBeLessThan(encodedFields.indexOf('s1:ä=')) diff --git a/scripts/ci-affected-scope.mjs b/scripts/ci-affected-scope.mjs index 277039507..7355e04c0 100644 --- a/scripts/ci-affected-scope.mjs +++ b/scripts/ci-affected-scope.mjs @@ -14,6 +14,8 @@ const DEPENDENCY_FIELDS = [ ] const FULL_PACKAGE_CONTROL_PATHS = new Set([ + '.github/workflows/ci.yml', + 'scripts/ci-result-gate.mjs', 'package.json', 'pnpm-workspace.yaml', 'tsconfig.base.json', @@ -50,6 +52,7 @@ export const RUNTIME_COMPONENTS = [ ] const FULL_INFRA_CONTROL_PATHS = new Set([ + '.github/workflows/ci.yml', 'governance/Dockerfile.container-bases', 'governance/container-images.json' ]) diff --git a/scripts/ci-affected-scope.test.mjs b/scripts/ci-affected-scope.test.mjs index 2359a3bd3..c2aa9b12e 100644 --- a/scripts/ci-affected-scope.test.mjs +++ b/scripts/ci-affected-scope.test.mjs @@ -79,8 +79,7 @@ test('documentation and QA policy changes do not fan out package tests', () => { assert.deepEqual( selectWorkspaceScope(projects, [ 'packages/direct/README.md', - 'governance/mutation-testing/policy.json', - '.github/workflows/ci.yml' + 'governance/mutation-testing/policy.json' ]), { direct: [], affected: [], build: [] } ) @@ -106,7 +105,7 @@ test('root toolchain changes deliberately retain full workspace coverage', () => }) test('infrastructure scope never rebuilds unrelated images for workflow-only changes', () => { - assert.deepEqual(selectInfraComponents(['.github/workflows/ci.yml']), []) + assert.deepEqual(selectInfraComponents(['.github/workflows/docs-deploy.yml']), []) assert.deepEqual( selectInfraComponents(['infra/message-box-server/src/index.ts']).map(entry => entry.component), ['message-box-server'] @@ -145,3 +144,10 @@ test('docs and conformance work are selected from their actual inputs', () => { assert.equal(conformanceIsAffected(['conformance/vectors/example.json']), true) assert.equal(conformanceIsAffected(['packages/direct/src/index.ts']), false) }) + +test('shared CI execution and result gates select the complete governed workspace', () => { + for (const file of ['.github/workflows/ci.yml', 'scripts/ci-result-gate.mjs']) { + assert.equal(selectWorkspaceScope(projects, [file]).direct.length, 4) + } + assert.equal(selectInfraComponents(['.github/workflows/ci.yml']).length, 8) +}) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index f89c974b3..950b578eb 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -64,9 +64,18 @@ test('CI shares one audited build across coverage and browser consumer lanes', ( test('CI skips empty duplicate lanes without weakening the aggregate gate', () => { const workflow = readFileSync(CI_PATH, 'utf8') - assert.match(workflow, /^ if: needs\.prepare\.outputs\.standard-packages != '\[\]'$/m) - assert.match(workflow, /^ if: needs\.prepare\.outputs\.dependent-test-packages != '\[\]'$/m) - assert.match(workflow, /^ if: needs\.prepare\.outputs\.coverage-other-packages != '\[\]'$/m) + assert.match( + workflow, + /^ if: always\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.standard-packages != '\[\]'$/m + ) + assert.match( + workflow, + /^ if: always\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.dependent-test-packages != '\[\]'$/m + ) + assert.match( + workflow, + /^ if: always\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.coverage-other-packages != '\[\]'$/m + ) assert.match( workflow, /^ matrix: \$\{\{ fromJSON\(needs\.prepare\.outputs\.coverage-other-matrix\) \}\}$/m @@ -115,7 +124,10 @@ test('CI bounds every job and allocates no runner for an empty infrastructure ma assert.match(job.source, /^ timeout-minutes: \d+$/m, `${job.name} must have a timeout`) } assert.match(workflow, /^ has-infra: \$\{\{ steps\.scope\.outputs\.has-infra \}\}$/m) - assert.match(workflow, /^ if: needs\.infra-scope\.outputs\.has-infra == 'true'$/m) + assert.match( + workflow, + /^ if: always\(\) && needs\.infra-scope\.result == 'success' && needs\.infra-scope\.outputs\.has-infra == 'true'$/m + ) assert.match(workflow, /\( "\$INFRA_RESULT" != "success" && "\$INFRA_RESULT" != "skipped" \)/) }) @@ -129,3 +141,23 @@ test('specialized workflows are bounded and required conformance checks always r assert.match(runtime, /^ timeout-minutes: 10$/m) assert.match(runtime, /^ if: needs\.scope\.outputs\.has-runtime == 'true'$/m) }) + +test('all selected execution jobs survive skipped ancestors and expose a strict final gate', () => { + const workflow = readFileSync(CI_PATH, 'utf8') + const jobs = workflowJobBlocks(workflow) + const selected = jobs.filter(job => /^ needs: (?:prepare|infra-scope)$/m.test(job.source)) + assert.equal(selected.length, 13) + for (const job of selected) { + assert.match( + job.source, + /^ if: always\(\) && needs\.(?:prepare|infra-scope)\.result == 'success' && /m, + job.name + ) + } + assert.match(workflow, /^ workflow_dispatch:$/m) + assert.doesNotMatch(workflow, /fail-fast: true/) + const gate = jobs.find(job => job.name === 'merge-gate').source + for (const job of selected) assert.ok(gate.includes(` - ${job.name}\n`), job.name) + assert.match(gate, /CI_NEEDS: \$\{\{ toJSON\(needs\) \}\}/) + assert.match(gate, /run: node scripts\/ci-result-gate\.mjs/) +}) diff --git a/scripts/ci-result-gate.mjs b/scripts/ci-result-gate.mjs new file mode 100644 index 000000000..d94efbde4 --- /dev/null +++ b/scripts/ci-result-gate.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +// A selected lane must succeed even when a PR-only ancestor is skipped on main. +// Missing scope outputs fail closed; they must never turn into an empty selection. +export function validateCiResults(needs, event) { + const errors = [] + const expectResult = (name, required) => { + const result = needs[name]?.result + if (result !== 'success' && (required || result !== 'skipped')) { + errors.push( + `${name}: expected ${required ? 'success' : 'success or scoped skip'}, got ${result ?? 'missing'}` + ) + } + } + const output = (job, key, type) => { + const raw = needs[job]?.outputs?.[key] + try { + const value = JSON.parse(raw) + if (type === 'array' ? !Array.isArray(value) : typeof value !== 'boolean') + throw new Error('type') + return type === 'array' ? value.length > 0 : value + } catch { + errors.push(`${job}.${key}: missing or invalid ${type} scope`) + return true + } + } + for (const name of [ + 'repository-health', + 'scope', + 'prepare', + 'infra-scope', + 'build-and-test', + 'mutation-quality' + ]) { + expectResult(name, true) + } + for (const [name, key, type] of [ + ['standard-tests', 'standard-packages', 'array'], + ['dependent-tests', 'dependent-test-packages', 'array'], + ['browser-packages', 'browser-packages', 'array'], + ['wallet-browser-platform', 'wallet_client', 'boolean'], + ['wallet-mobile-platform', 'wallet_mobile', 'boolean'], + ['coverage-sdk', 'sdk', 'boolean'], + ['coverage-did', 'did', 'boolean'], + ['coverage-wallet', 'wallet', 'boolean'], + ['coverage-wallet-monitor', 'wallet', 'boolean'], + ['coverage-verifast', 'verifast', 'boolean'], + ['coverage-other', 'coverage-other-packages', 'array'], + ['coverage-upload', 'coverage-required', 'boolean'], + ['mutation-tests', 'mutation-targets', 'array'] + ]) { + expectResult(name, output('prepare', key, type)) + } + expectResult('infra', output('infra-scope', 'has-infra', 'boolean')) + expectResult('docs-validate', output('scope', 'docs', 'boolean')) + expectResult('conformance', output('scope', 'conformance', 'boolean')) + expectResult('sonar-zero-findings', event === 'pull_request') + expectResult('dependency-review', event === 'pull_request') + return errors +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + const errors = validateCiResults(JSON.parse(process.env.CI_NEEDS ?? ''), process.env.CI_EVENT) + if (errors.length > 0) throw new Error(errors.join('\n')) + console.log('Every selected CI lane completed successfully; remaining skips match scope.') + } catch (error) { + console.error(error instanceof Error ? error.message : 'Invalid CI result evidence') + process.exitCode = 1 + } +} diff --git a/scripts/ci-result-gate.test.mjs b/scripts/ci-result-gate.test.mjs new file mode 100644 index 000000000..6ae76a9d2 --- /dev/null +++ b/scripts/ci-result-gate.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { validateCiResults } from './ci-result-gate.mjs' + +function evidence(selected = true) { + const required = [ + 'repository-health', + 'scope', + 'prepare', + 'infra-scope', + 'build-and-test', + 'mutation-quality' + ] + const lanes = [ + 'standard-tests', + 'dependent-tests', + 'browser-packages', + 'wallet-browser-platform', + 'wallet-mobile-platform', + 'coverage-sdk', + 'coverage-did', + 'coverage-wallet', + 'coverage-wallet-monitor', + 'coverage-verifast', + 'coverage-other', + 'coverage-upload', + 'mutation-tests', + 'infra', + 'docs-validate', + 'conformance' + ] + const needs = Object.fromEntries( + [...required, ...lanes].map(name => [ + name, + { result: required.includes(name) || selected ? 'success' : 'skipped' } + ]) + ) + needs.prepare.outputs = Object.fromEntries( + [ + 'standard-packages', + 'dependent-test-packages', + 'browser-packages', + 'coverage-other-packages', + 'mutation-targets' + ].map(key => [key, selected ? '["selected"]' : '[]']) + ) + Object.assign( + needs.prepare.outputs, + Object.fromEntries( + [ + 'wallet_client', + 'wallet_mobile', + 'sdk', + 'did', + 'wallet', + 'verifast', + 'coverage-required' + ].map(key => [key, String(selected)]) + ) + ) + needs.scope.outputs = { docs: String(selected), conformance: String(selected) } + needs['infra-scope'].outputs = { 'has-infra': String(selected) } + needs['sonar-zero-findings'] = { result: 'skipped' } + needs['dependency-review'] = { result: 'skipped' } + return needs +} + +test('main and full dispatch accept selected successes with PR-only gates skipped', () => { + for (const event of ['push', 'workflow_dispatch']) { + assert.deepEqual(validateCiResults(evidence(), event), []) + assert.deepEqual(validateCiResults(evidence(false), event), []) + } +}) + +test('a green aggregate cannot conceal any selected lane missing, cancelled or skipped', () => { + const baseline = evidence() + for (const name of Object.keys(baseline).filter( + name => !['sonar-zero-findings', 'dependency-review'].includes(name) + )) { + for (const result of ['skipped', 'cancelled', 'failure', undefined]) { + const needs = structuredClone(baseline) + needs[name].result = result + assert.ok( + validateCiResults(needs, 'push').some(error => error.startsWith(`${name}:`)), + `${name}: ${result}` + ) + } + } +}) + +test('missing or malformed scope cannot authorize skips', () => { + for (const raw of [undefined, '', 'null', '{}', '"false"']) { + const needs = evidence(false) + needs.prepare.outputs.sdk = raw + needs.prepare.outputs['standard-packages'] = raw + const errors = validateCiResults(needs, 'push') + assert.ok(errors.some(error => error.includes('prepare.sdk'))) + assert.ok(errors.some(error => error.includes('prepare.standard-packages'))) + } +}) + +test('PRs require real analysis and dependency review; out-of-scope failures still fail', () => { + const needs = evidence(false) + assert.equal(validateCiResults(needs, 'pull_request').length, 2) + needs['sonar-zero-findings'].result = 'success' + needs['dependency-review'].result = 'success' + assert.deepEqual(validateCiResults(needs, 'pull_request'), []) + needs['coverage-sdk'].result = 'failure' + assert.match(validateCiResults(needs, 'pull_request').join('\n'), /coverage-sdk/) +}) From 86e0839f7d249c518aeabcff55bce15e9f943ebc Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 22 Sep 2026 18:06:20 -0700 Subject: [PATCH 2/3] test(chirp): cover iterator cleanup when return hook changes --- .../network/chirp/test/primitives.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/network/chirp/test/primitives.test.ts b/packages/network/chirp/test/primitives.test.ts index eb0b3a314..64873f297 100644 --- a/packages/network/chirp/test/primitives.test.ts +++ b/packages/network/chirp/test/primitives.test.ts @@ -334,6 +334,26 @@ describe('byte sources and bounded cache', () => { } }) + test('finishes local cancellation when an iterator withdraws its optional return hook', async () => { + let reads = 0 + const finish = jest.fn(async () => ({ done: true as const, value: undefined })) + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => ({ done: false as const, value: Uint8Array.of(7) }), + get return() { + return reads++ === 0 ? finish : undefined + } + } + } + } + const bytes = toAsyncBytes(source) + await expect(bytes.next()).resolves.toEqual({ done: false, value: Uint8Array.of(7) }) + await expect(bytes.return(undefined)).resolves.toEqual({ done: true, value: undefined }) + expect(finish).not.toHaveBeenCalled() + await expect(bytes.next()).resolves.toEqual({ done: true, value: undefined }) + }) + test('rejects unsupported and non-byte source chunks', async () => { await expect(collect({} as CHIRPByteSource)).rejects.toMatchObject({ code: 'ERR_CHIRP_SOURCE' }) const sparse: number[] = [] From 2279bc07ba9f12eca448bf916287210fe773199d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 22 Sep 2026 18:11:34 -0700 Subject: [PATCH 3/3] fix(ci): honor cancellation in prepared execution lanes --- .github/workflows/ci.yml | 36 +++++++++++++++---------------- docs/reference/ci-performance.md | 3 ++- scripts/ci-orchestration.test.mjs | 14 ++++++------ 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f3ea15bb..82d459984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,7 @@ jobs: needs: - early-gates - scope - if: always() && needs.early-gates.result == 'success' && needs.scope.result == 'success' + if: always() && !cancelled() && needs.early-gates.result == 'success' && needs.scope.result == 'success' runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -436,7 +436,7 @@ jobs: mutation-tests: name: Mutation / ${{ matrix.target }} - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.mutation-targets != '[]' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.mutation-targets != '[]' needs: prepare runs-on: ubuntu-latest # The governed air-gap codec target currently instruments 352 mutants and @@ -498,7 +498,7 @@ jobs: standard-tests: name: Tests / non-coverage packages - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.standard-packages != '[]' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.standard-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 25 @@ -541,7 +541,7 @@ jobs: dependent-tests: name: Tests / affected dependents (${{ matrix.shard }}/${{ matrix.total }}) - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.dependent-test-packages != '[]' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.dependent-test-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 @@ -605,7 +605,7 @@ jobs: browser-packages: name: Platform / browser packages (${{ matrix.shard }}/${{ matrix.total }}) - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.browser-packages != '[]' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.browser-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 25 @@ -669,7 +669,7 @@ jobs: wallet-browser-platform: name: Platform / wallet browser - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet_client == 'true' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet_client == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 20 @@ -703,7 +703,7 @@ jobs: wallet-mobile-platform: name: Platform / wallet mobile - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet_mobile == 'true' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet_mobile == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 20 @@ -742,7 +742,7 @@ jobs: coverage-sdk: name: Coverage / SDK - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.sdk == 'true' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.sdk == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 @@ -793,7 +793,7 @@ jobs: coverage-did: name: Coverage / DID - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.did == 'true' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.did == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 20 @@ -828,7 +828,7 @@ jobs: coverage-wallet: name: Coverage / wallet-toolbox (${{ matrix.shard }}/4) - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet == 'true' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 40 @@ -877,7 +877,7 @@ jobs: coverage-wallet-monitor: name: Coverage / wallet-toolbox monitor - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet == 'true' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.wallet == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 25 @@ -922,7 +922,7 @@ jobs: coverage-verifast: name: Coverage / VeriFast - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.verifast == 'true' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.verifast == 'true' needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 @@ -974,7 +974,7 @@ jobs: coverage-other: name: Coverage / other affected packages (${{ matrix.shard }}/${{ matrix.total }}) - if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.coverage-other-packages != '[]' + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.coverage-other-packages != '[]' needs: prepare runs-on: ubuntu-latest timeout-minutes: 35 @@ -1060,7 +1060,7 @@ jobs: coverage-upload: name: Coverage / aggregate upload if: >- - always() && + always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.coverage-required == 'true' && (needs.coverage-other.result == 'success' || needs.coverage-other.result == 'skipped') && @@ -1215,7 +1215,7 @@ jobs: needs: - early-gates - scope - if: always() && needs.early-gates.result == 'success' && needs.scope.result == 'success' + if: always() && !cancelled() && needs.early-gates.result == 'success' && needs.scope.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} @@ -1231,7 +1231,7 @@ jobs: infra: name: Infra / ${{ matrix.display }} - if: always() && needs.infra-scope.result == 'success' && needs.infra-scope.outputs.has-infra == 'true' + if: always() && !cancelled() && needs.infra-scope.result == 'success' && needs.infra-scope.outputs.has-infra == 'true' needs: infra-scope runs-on: ubuntu-latest timeout-minutes: 35 @@ -1322,7 +1322,7 @@ jobs: # Run on every PR (cheap and protects the docs site) and on pushes that touch docs # The build step inside will fail fast on frontmatter or link problems before they reach production if: >- - always() && + always() && !cancelled() && needs.early-gates.result == 'success' && needs.scope.result == 'success' && needs.scope.outputs.docs == 'true' @@ -1368,7 +1368,7 @@ jobs: - early-gates - scope if: >- - always() && + always() && !cancelled() && needs.early-gates.result == 'success' && needs.scope.result == 'success' && needs.scope.outputs.conformance == 'true' diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index aabf6fc38..964bebb91 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -82,7 +82,8 @@ uses the workflow's all-scope path instead of comparing only the latest commit. Source merges still validate their affected scope automatically. Every execution lane uses an explicit successful-preparation condition that -survives intentionally skipped PR-only gates on a main push. The final result +survives intentionally skipped PR-only gates on a main push and honors explicit +workflow cancellation so obsolete runs cannot hold the concurrency slot. The final result gate independently checks scope outputs against each job result: selected jobs must succeed; missing, cancelled or skipped selected jobs fail the merge gate. Only genuinely unselected lanes and main's PR-only checks may be skipped. diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 950b578eb..bd3f8d901 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -66,15 +66,15 @@ test('CI skips empty duplicate lanes without weakening the aggregate gate', () = assert.match( workflow, - /^ if: always\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.standard-packages != '\[\]'$/m + /^ if: always\(\) && !cancelled\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.standard-packages != '\[\]'$/m ) assert.match( workflow, - /^ if: always\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.dependent-test-packages != '\[\]'$/m + /^ if: always\(\) && !cancelled\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.dependent-test-packages != '\[\]'$/m ) assert.match( workflow, - /^ if: always\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.coverage-other-packages != '\[\]'$/m + /^ if: always\(\) && !cancelled\(\) && needs\.prepare\.result == 'success' && needs\.prepare\.outputs\.coverage-other-packages != '\[\]'$/m ) assert.match( workflow, @@ -103,13 +103,13 @@ test('CI push jobs survive intentionally skipped pull-request-only gates', () => const workflow = readFileSync(CI_PATH, 'utf8') const jobs = Object.fromEntries(workflowJobBlocks(workflow).map(job => [job.name, job.source])) const directGateCondition = - "always() && needs.early-gates.result == 'success' && needs.scope.result == 'success'" + "always() && !cancelled() && needs.early-gates.result == 'success' && needs.scope.result == 'success'" assert.ok(jobs.prepare.includes(` if: ${directGateCondition}\n`)) assert.ok(jobs['infra-scope'].includes(` if: ${directGateCondition}\n`)) for (const jobName of ['docs-validate', 'conformance']) { assert.match(jobs[jobName], /^ if: >-$/m) - assert.match(jobs[jobName], /^ always\(\) &&$/m) + assert.match(jobs[jobName], /^ always\(\) && !cancelled\(\) &&$/m) assert.match(jobs[jobName], /^ needs\.early-gates\.result == 'success' &&$/m) assert.match(jobs[jobName], /^ needs\.scope\.result == 'success' &&$/m) } @@ -126,7 +126,7 @@ test('CI bounds every job and allocates no runner for an empty infrastructure ma assert.match(workflow, /^ has-infra: \$\{\{ steps\.scope\.outputs\.has-infra \}\}$/m) assert.match( workflow, - /^ if: always\(\) && needs\.infra-scope\.result == 'success' && needs\.infra-scope\.outputs\.has-infra == 'true'$/m + /^ if: always\(\) && !cancelled\(\) && needs\.infra-scope\.result == 'success' && needs\.infra-scope\.outputs\.has-infra == 'true'$/m ) assert.match(workflow, /\( "\$INFRA_RESULT" != "success" && "\$INFRA_RESULT" != "skipped" \)/) }) @@ -150,7 +150,7 @@ test('all selected execution jobs survive skipped ancestors and expose a strict for (const job of selected) { assert.match( job.source, - /^ if: always\(\) && needs\.(?:prepare|infra-scope)\.result == 'success' && /m, + /^ if: always\(\) && !cancelled\(\) && needs\.(?:prepare|infra-scope)\.result == 'success' && /m, job.name ) }