From 1f090af1c4daf3ddd0e536d99351fad4d1a9b73e Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Wed, 7 Oct 2026 02:34:39 +0000 Subject: [PATCH 1/2] fix(release): validate published consumer compatibility --- .github/scripts/check-release-intent.sh | 7 + .github/workflows/ci.yml | 1 + src/docs/maintainers/release.md | 34 +++ .../tools/package-extension-cargo-facades.mts | 14 +- .../package-extension-cargo-facades.test.mts | 41 +++- .../external/pg_hashids/release.toml | 1 + src/extensions/external/pg_ivm/release.toml | 1 + .../external/pg_textsearch/release.toml | 1 + .../external/pg_uuidv7/release.toml | 1 + src/extensions/external/pgtap/release.toml | 1 + src/extensions/external/postgis/release.toml | 1 + src/extensions/external/vector/release.toml | 1 + src/native/runtime/release.toml | 1 + src/wasix/postgres-tools/release.toml | 5 + src/wasix/postgres-tools/ts/package.json | 3 +- src/wasix/postgres-tools/ts/tools/package.mts | 14 +- .../tools/wasix-tools-typescript-package.mts | 5 +- .../wasix-tools-typescript-package.test.mts | 4 + .../ts/tools/wasix-typescript-package.mts | 30 +++ .../tools/wasix-typescript-package.test.mts | 27 ++- tools/release/check-release-intent.test.sh | 68 ++++++ tools/release/check-release-metadata.mts | 2 + tools/release/check-release-versions.sh | 2 +- tools/release/check_registry_publication.mts | 30 ++- tools/release/check_release_versions.mts | 7 + tools/release/close-release-candidate.sh | 1 + tools/release/consumer-compatibility.mts | 160 ++++++++++++++ tools/release/consumer-compatibility.test.mts | 198 ++++++++++++++++++ .../release/independent-version-pins.test.mts | 19 ++ tools/release/moon.yml | 7 + .../prepare-release-candidate.test.mts | 11 + tools/release/public-consumer-smoke.mts | 20 ++ tools/release/public-consumer-smoke.test.mts | 94 +++++++++ tools/release/publication-lock.mts | 66 +++++- tools/release/publication-lock.test.mts | 87 +++++++- tools/release/release-history.mts | 5 +- tools/release/release-history.test.mts | 4 +- tools/release/release-history.test.sh | 9 +- .../release-verification-shell.test.sh | 6 +- tools/release/release-version-tags.test.sh | 11 + 40 files changed, 967 insertions(+), 33 deletions(-) create mode 100644 tools/release/consumer-compatibility.mts create mode 100644 tools/release/consumer-compatibility.test.mts diff --git a/.github/scripts/check-release-intent.sh b/.github/scripts/check-release-intent.sh index 45633a0c2..bc010efcb 100755 --- a/.github/scripts/check-release-intent.sh +++ b/.github/scripts/check-release-intent.sh @@ -131,6 +131,7 @@ EOF exit 1 fi +consumer_products_json='' if [[ "${is_release_pr}" == true ]]; then base_commit="$(git rev-parse "${base_ref}^{commit}")" head_parent="$(git rev-parse "${head_ref}^{commit}^")" @@ -147,6 +148,12 @@ if [[ "${is_release_pr}" == true ]]; then bash tools/release/release-please-state.sh "$PWD" HEAD bash tools/release/with-release-history.sh "$PWD" "${head_ref}" tools/dev/bun.sh tools/release/verify-release-commit.mts \ --products-json "${release_products_json}" \ --head-ref "${head_ref}" + consumer_products_json="${release_products_json}" +elif [[ "${event_name}" == workflow_dispatch && "${full_ref}" == refs/heads/main && "${CI_RELEASE_PRODUCTS_JSON:-[]}" != '[]' ]]; then + consumer_products_json="${CI_RELEASE_PRODUCTS_JSON}" +fi +if [[ -n "${consumer_products_json}" ]]; then + bash tools/release/with-product-history.sh "$PWD" "${head_ref}" '' @workspace bash tools/dev/bun.sh tools/release/consumer-compatibility.mts "${consumer_products_json}" fi release_plan="$(bash tools/release/release-plan.sh --base-ref "${base_ref}" --head-ref "${head_ref}" --format json)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c84f85b6b..c2713eb08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,6 +260,7 @@ jobs: HEAD_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} CI_EVENT_NAME: ${{ github.event_name }} CI_FULL_REF: ${{ github.ref }} + CI_RELEASE_PRODUCTS_JSON: ${{ inputs.release_products_json || '[]' }} run: | subject="${PR_TITLE:-}" if [ -z "$subject" ]; then diff --git a/src/docs/maintainers/release.md b/src/docs/maintainers/release.md index ca256ea59..bd7ab660e 100644 --- a/src/docs/maintainers/release.md +++ b/src/docs/maintainers/release.md @@ -383,6 +383,40 @@ assembles release packages; the retired dry-run wrapper did not assemble them either. Use the selected products' package and artifact/consumer test tasks for local package rehearsal. +Release admission checks consumer combinations before uploading registry bytes. +The CI release-intent job runs the same compatibility gate on generated release +PRs, their main release commits and explicitly selected main qualification +dispatches; ordinary source PRs and feature diagnostics keep source qualification. +`consumer-compatibility.mts` reads selected consumers' declared pins and reads +unselected dependencies from their immutable product tags. It compares the WASIX +SDK runtime with the runtime embedded by its exact N-API dependency, follows +declared SDK dependencies through tools and React Native, checks consumers' +own runtime pins against those SDKs, and checks every +external extension against consumers that enforce exact runtime identity. +Cargo SDK admission also checks published, unselected extension facades' API +requirements. A workspace `qualificationOnly` fixture cannot satisfy these +release contracts. A new runtime SDK must declare its extension compatibility +policy before metadata admission. + +Cargo extension facades use compatible SemVer requirements for SDK, build-helper +and binding APIs; extension payload and AOT carrier versions remain exact. +The shared facade generator is a release source of every external extension and +the native runtime product that owns the contrib facade, so Release Please +includes those products when its shipped output changes. WASIX tools declare +their SDK pin explicitly instead of deriving it from the current workspace SDK. +These rules preserve independent packaging versions; they do not establish +cross-runtime extension compatibility or waive ABI, AOT, export or integrity +checks. Incompatible combinations require a fresh matching release candidate. + +Freezing the publication candidate additionally checks the unmodified WASIX SDK +tarball's declared pins and every selected N-API tarball, including macOS carriers +on a Linux preparation host. Packing and atomically installing a frozen candidate +repeat that check, so approval-run reuse and bootstrap retries cannot bypass it. +The post-publication npm check validates the actual +installed carrier for every installed WASIX SDK, including unselected historical +SDKs and nested dependency installations. Source qualification and installed +release compatibility remain separate requirements. + For a release packaging failure, download the failed candidate's inputs from its exact CI run into an isolated checkout and run the same assembly entrypoint: diff --git a/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts b/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts index fa045561a..919cbc5b4 100644 --- a/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts +++ b/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts @@ -139,8 +139,8 @@ export function writeFacadeSource(product, outputRoot, { dependencyPaths = {} } `[target.'cfg(${cfg})'.dependencies]\n${name} = { version = "=${version}", optional = true${dependencyPaths[name] ? `, path = ${JSON.stringify(dependencyPaths[name])}` : ''} }`, ); } - const dependency = (name, version, optional = true) => - `${name} = { version = "=${version}"${optional ? ', optional = true' : ''}, default-features = false${dependencyPaths[name] ? `, path = ${JSON.stringify(dependencyPaths[name])}` : ''} }`; + const dependency = (name, version, exact = true, optional = true) => + `${name} = { version = "${exact ? '=' : '^'}${version}"${optional ? ', optional = true' : ''}, default-features = false${dependencyPaths[name] ? `, path = ${JSON.stringify(dependencyPaths[name])}` : ''} }`; const cfgForTriple = (triple) => rustNativeTargetCfg( { @@ -162,12 +162,16 @@ export function writeFacadeSource(product, outputRoot, { dependencyPaths = {} } const sdkVersion = currentProductVersionSync('oliphaunt-rust'); const bindingVersion = currentProductVersionSync('liboliphaunt-native-bindings'); const optionalDependencies = [ - dependency('liboliphaunt-native-bindings', bindingVersion), + dependency('liboliphaunt-native-bindings', bindingVersion, false), ...(wasixName === null ? [] : [ dependency(wasixName, version), - dependency('oliphaunt-wasix', currentProductVersionSync('oliphaunt-wasix-rust')).replace( + dependency( + 'oliphaunt-wasix', + currentProductVersionSync('oliphaunt-wasix-rust'), + false, + ).replace( 'default-features = false', 'default-features = false, features = ["extensions"]', ), @@ -206,7 +210,7 @@ ${features.join('\n')} ${optionalDependencies} [build-dependencies] -${dependency('oliphaunt-build', sdkVersion)} +${dependency('oliphaunt-build', sdkVersion, false)} ${targetDependencies.join('\n\n')} diff --git a/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.test.mts b/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.test.mts index 5d110a9d6..7a5490234 100644 --- a/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.test.mts +++ b/src/extensions/artifacts/packages/tools/package-extension-cargo-facades.test.mts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { + cpSync, mkdirSync, mkdtempSync, readdirSync, @@ -9,6 +10,7 @@ import { statSync, writeFileSync, } from 'node:fs'; +import { packagedCargoManifestText } from '../../../../../tools/packaging/cargo-source-package.mts'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { @@ -49,22 +51,37 @@ if (['prepare-compiler', 'verify-compiler'].includes(process.argv[2])) { 'package-extension-cargo-facades.test', ); const products = ['oliphaunt-extension-contrib-pg18', 'oliphaunt-extension-vector']; + const helperSource = path.resolve( + import.meta.dir, + '../../../../native/sdks/rust/crates/oliphaunt-build', + ); + const helper = path.join(root, 'build-helper'); + cpSync(helperSource, helper, { recursive: true }); + const nextPatch = (version) => { + const parts = version.split('.'); + parts[2] = String(Number(parts[2]) + 1); + return parts.join('.'); + }; + writeFileSync( + path.join(helper, 'Cargo.toml'), + packagedCargoManifestText(readFileSync(path.join(helper, 'Cargo.toml'), 'utf8')).replace( + /^version = "[^"]+"/mu, + `version = "${nextPatch(currentProductVersionSync('oliphaunt-rust'))}"`, + ), + ); const dependencyPaths = { 'liboliphaunt-native-bindings': path.resolve( import.meta.dir, '../../../../native/rust-bindings', ), - 'oliphaunt-build': path.resolve( - import.meta.dir, - '../../../../native/sdks/rust/crates/oliphaunt-build', - ), + 'oliphaunt-build': helper, }; // The native consumer does not enable WASIX, but Cargo resolves optional coordinates. const wasixStub = path.join(root, 'wasix-sdk'); mkdirSync(path.join(wasixStub, 'src'), { recursive: true }); writeFileSync( path.join(wasixStub, 'Cargo.toml'), - `[package]\nname = "oliphaunt-wasix"\nversion = "${currentProductVersionSync('oliphaunt-wasix-rust')}"\nedition = "2024"\n[features]\nextensions = []\n[workspace]\n`, + `[package]\nname = "oliphaunt-wasix"\nversion = "${nextPatch(currentProductVersionSync('oliphaunt-wasix-rust'))}"\nedition = "2024"\n[features]\nextensions = []\n[workspace]\n`, ); writeFileSync(path.join(wasixStub, 'src/lib.rs'), ''); dependencyPaths['oliphaunt-wasix'] = wasixStub; @@ -180,7 +197,7 @@ fixture-native-tools = { path = ${JSON.stringify(tools)} } fixture-broker = { path = ${JSON.stringify(broker)} } [build-dependencies] -oliphaunt-build = { path = ${JSON.stringify(path.resolve(import.meta.dir, '../../../../native/sdks/rust/crates/oliphaunt-build'))} } +oliphaunt-build = { path = ${JSON.stringify(helper)} } [workspace] `, @@ -294,6 +311,18 @@ describe('exact extension Cargo facade', () => { 'dep:oliphaunt-extension-pgtap-wasix', ]); expect(pkg.cratePath.endsWith('.crate')).toBe(true); + expect(manifest.dependencies['oliphaunt-wasix'].version).toBe( + `^${currentProductVersionSync('oliphaunt-wasix-rust')}`, + ); + expect(manifest.dependencies['liboliphaunt-native-bindings'].version).toBe( + `^${currentProductVersionSync('liboliphaunt-native-bindings')}`, + ); + expect(manifest['build-dependencies']['oliphaunt-build'].version).toBe( + `^${currentProductVersionSync('oliphaunt-rust')}`, + ); + expect(manifest.dependencies['oliphaunt-extension-pgtap-wasix'].version).toBe( + `=${pkg.version}`, + ); }); test('the native-owned contrib facade has no WASIX carrier dependency', () => { diff --git a/src/extensions/external/pg_hashids/release.toml b/src/extensions/external/pg_hashids/release.toml index 195082ae2..780c229a7 100644 --- a/src/extensions/external/pg_hashids/release.toml +++ b/src/extensions/external/pg_hashids/release.toml @@ -1,6 +1,7 @@ id = "oliphaunt-extension-pg-hashids" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = [ "github-release-assets", "npm", diff --git a/src/extensions/external/pg_ivm/release.toml b/src/extensions/external/pg_ivm/release.toml index 98694f8b5..d1fcb759d 100644 --- a/src/extensions/external/pg_ivm/release.toml +++ b/src/extensions/external/pg_ivm/release.toml @@ -1,6 +1,7 @@ id = "oliphaunt-extension-pg-ivm" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = [ "github-release-assets", "npm", diff --git a/src/extensions/external/pg_textsearch/release.toml b/src/extensions/external/pg_textsearch/release.toml index 579c76c81..a7bf5526d 100644 --- a/src/extensions/external/pg_textsearch/release.toml +++ b/src/extensions/external/pg_textsearch/release.toml @@ -1,6 +1,7 @@ id = "oliphaunt-extension-pg-textsearch" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = [ "github-release-assets", "npm", diff --git a/src/extensions/external/pg_uuidv7/release.toml b/src/extensions/external/pg_uuidv7/release.toml index 8557599b5..8cdb62d65 100644 --- a/src/extensions/external/pg_uuidv7/release.toml +++ b/src/extensions/external/pg_uuidv7/release.toml @@ -1,6 +1,7 @@ id = "oliphaunt-extension-pg-uuidv7" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = [ "github-release-assets", "npm", diff --git a/src/extensions/external/pgtap/release.toml b/src/extensions/external/pgtap/release.toml index b275e5d61..dfe34b52b 100644 --- a/src/extensions/external/pgtap/release.toml +++ b/src/extensions/external/pgtap/release.toml @@ -1,6 +1,7 @@ id = "oliphaunt-extension-pgtap" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = [ "github-release-assets", "npm", diff --git a/src/extensions/external/postgis/release.toml b/src/extensions/external/postgis/release.toml index eb0f78ca7..7e81407be 100644 --- a/src/extensions/external/postgis/release.toml +++ b/src/extensions/external/postgis/release.toml @@ -1,6 +1,7 @@ id = "oliphaunt-extension-postgis" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = [ "github-release-assets", "npm", diff --git a/src/extensions/external/vector/release.toml b/src/extensions/external/vector/release.toml index 9a2f9f9bf..5b4cc6b82 100644 --- a/src/extensions/external/vector/release.toml +++ b/src/extensions/external/vector/release.toml @@ -1,6 +1,7 @@ id = "oliphaunt-extension-vector" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = [ "github-release-assets", "npm", diff --git a/src/native/runtime/release.toml b/src/native/runtime/release.toml index b80408efb..dcaa55cce 100644 --- a/src/native/runtime/release.toml +++ b/src/native/runtime/release.toml @@ -1,6 +1,7 @@ id = "liboliphaunt-native" owner = "@oliphaunt/core" kind = "native-core" +shared_source_paths = ["src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts"] publish_targets = ["github-release-assets", "npm", "maven-central", "crates-io"] registry_packages = [ "crates:liboliphaunt-native-linux-arm64-gnu", diff --git a/src/wasix/postgres-tools/release.toml b/src/wasix/postgres-tools/release.toml index 04bc838f2..d9b1e6dac 100644 --- a/src/wasix/postgres-tools/release.toml +++ b/src/wasix/postgres-tools/release.toml @@ -26,3 +26,8 @@ parser = "json:oliphaunt.runtimeVersion" source_product = "liboliphaunt-wasix" path = "src/wasix/postgres-tools/npm/package.json" parser = "json:oliphaunt.runtimeVersion" + +[compatibility_versions.oliphaunt-wasix-tools-sdk] +source_product = "oliphaunt-wasix-ts" +path = "src/wasix/postgres-tools/ts/package.json" +parser = "json:oliphaunt.wasixSdkVersion" diff --git a/src/wasix/postgres-tools/ts/package.json b/src/wasix/postgres-tools/ts/package.json index 516f9e8ad..1ae6ff948 100644 --- a/src/wasix/postgres-tools/ts/package.json +++ b/src/wasix/postgres-tools/ts/package.json @@ -20,7 +20,8 @@ }, "oliphaunt": { "runtimeProduct": "liboliphaunt-wasix", - "runtimeVersion": "0.3.1" + "runtimeVersion": "0.3.1", + "wasixSdkVersion": "0.2.1" }, "exports": { ".": { diff --git a/src/wasix/postgres-tools/ts/tools/package.mts b/src/wasix/postgres-tools/ts/tools/package.mts index cbd41e38c..99ef4284f 100755 --- a/src/wasix/postgres-tools/ts/tools/package.mts +++ b/src/wasix/postgres-tools/ts/tools/package.mts @@ -16,10 +16,11 @@ const SOURCE = path.join(ROOT, 'src/wasix/postgres-tools/ts'); const CARRIER = '@oliphaunt/liboliphaunt-wasix-tools'; const BINDING = '@oliphaunt/wasix-ts'; -export function prepareWasixToolsTypescriptPackage(packageDir, bindingVersion) { - if (!/^\d+\.\d+\.\d+$/u.test(bindingVersion)) throw new Error('binding version must be exact'); +export function prepareWasixToolsTypescriptPackage(packageDir) { const manifestFile = path.join(packageDir, 'package.json'); const manifest = JSON.parse(readFileSync(manifestFile, 'utf8')); + const bindingVersion = manifest.oliphaunt?.wasixSdkVersion; + if (!/^\d+\.\d+\.\d+$/u.test(bindingVersion)) throw new Error('binding version must be exact'); const runtimeVersion = manifest.oliphaunt?.runtimeVersion; if (!/^\d+\.\d+\.\d+$/u.test(runtimeVersion)) throw new Error('runtime version must be exact'); manifest.dependencies = { [CARRIER]: manifest.version }; @@ -31,7 +32,7 @@ export function prepareWasixToolsTypescriptPackage(packageDir, bindingVersion) { return manifest; } -export function stageWasixToolsTypescriptPackage(outputDir, bindingVersion) { +export function stageWasixToolsTypescriptPackage(outputDir) { const destination = path.resolve(ROOT, outputDir); const relative = path.relative(ROOT, destination); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { @@ -49,13 +50,10 @@ export function stageWasixToolsTypescriptPackage(outputDir, bindingVersion) { path.join(ROOT, 'src/wasix/postgres-tools/CHANGELOG.md'), path.join(destination, 'CHANGELOG.md'), ); - return prepareWasixToolsTypescriptPackage(destination, bindingVersion); + return prepareWasixToolsTypescriptPackage(destination); } if (import.meta.main) { const output = process.argv[2] ?? 'target/oliphaunt-wasix-tools-ts/package'; - const binding = JSON.parse( - readFileSync(path.join(ROOT, 'src/wasix/sdks/ts/package.json'), 'utf8'), - ); - stageWasixToolsTypescriptPackage(output, binding.version); + stageWasixToolsTypescriptPackage(output); } diff --git a/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.mts b/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.mts index d436e0654..cf9664cc7 100644 --- a/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.mts +++ b/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.mts @@ -21,9 +21,9 @@ function fail(message) { throw new Error(`${TOOL}: ${message}`); } -export function prepareWasixToolsTypescriptPackage(packageDir, bindingVersion) { +export function prepareWasixToolsTypescriptPackage(packageDir) { const root = path.resolve(packageDir); - const manifest = prepareProductPackage(root, bindingVersion); + const manifest = prepareProductPackage(root); assertReleaseNoticesInDirectory(root, NOTICE_OPTIONS); assertWasixToolsTypescriptManifest(manifest, `${PACKAGE_NAME} staged package`); return manifest; @@ -49,6 +49,7 @@ export function assertWasixToolsTypescriptManifest(manifest, label = PACKAGE_NAM dependencies[TOOLS_CARRIER] !== manifest.version || JSON.stringify(Object.keys(peerDependencies)) !== JSON.stringify([WASIX_BINDING]) || !EXACT_VERSION.test(peerDependencies[WASIX_BINDING]) || + manifest.oliphaunt?.wasixSdkVersion !== peerDependencies[WASIX_BINDING] || manifest.oliphaunt?.runtimeProduct !== 'liboliphaunt-wasix' || !EXACT_VERSION.test(manifest.oliphaunt?.runtimeVersion) || Object.keys(manifest.optionalDependencies ?? {}).length > 0 diff --git a/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.test.mts b/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.test.mts index 2c89d62d1..eeab10e64 100644 --- a/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.test.mts +++ b/src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.test.mts @@ -17,6 +17,7 @@ function manifest() { oliphaunt: { runtimeProduct: 'liboliphaunt-wasix', runtimeVersion: '4.5.6', + wasixSdkVersion: '7.8.9', }, dependencies: { '@oliphaunt/liboliphaunt-wasix-tools': '1.2.3', @@ -43,5 +44,8 @@ describe('WASIX TypeScript tools package contract', () => { const extra = manifest(); extra.dependencies.other = '1.0.0'; expect(() => assertWasixToolsTypescriptManifest(extra)).toThrow(/exact WASIX binding/); + const mismatch = manifest(); + mismatch.oliphaunt.wasixSdkVersion = '7.8.10'; + expect(() => assertWasixToolsTypescriptManifest(mismatch)).toThrow(/exact WASIX binding/); }); }); diff --git a/src/wasix/sdks/ts/tools/wasix-typescript-package.mts b/src/wasix/sdks/ts/tools/wasix-typescript-package.mts index ee221a40d..0662968d0 100644 --- a/src/wasix/sdks/ts/tools/wasix-typescript-package.mts +++ b/src/wasix/sdks/ts/tools/wasix-typescript-package.mts @@ -97,6 +97,36 @@ export function assertWasixTypescriptManifest(manifest, label = `${PACKAGE_NAME} return manifest; } +export function assertWasixTypescriptNativeCarrier(manifest, carrier) { + const sdk = manifest.oliphaunt; + const native = carrier.oliphaunt; + if ( + manifest.name !== PACKAGE_NAME || + sdk?.runtimeProduct !== 'liboliphaunt-wasix' || + sdk?.wasixNapiProduct !== NATIVE_PRODUCT || + !/^\d+\.\d+\.\d+$/u.test(sdk?.runtimeVersion ?? '') || + !/^\d+\.\d+\.\d+$/u.test(sdk?.wasixNapiVersion ?? '') || + !Number.isSafeInteger(sdk?.wasixAddonAbiVersion) || + sdk.wasixAddonAbiVersion < 1 || + !Number.isSafeInteger(sdk?.nodeApiVersion) || + sdk.nodeApiVersion < 1 || + Object.hasOwn(sdk, 'qualificationOnly') || + Object.hasOwn(native ?? {}, 'qualificationOnly') || + !NATIVE_PACKAGES.includes(carrier.name) || + carrier.version !== sdk?.wasixNapiVersion || + manifest.optionalDependencies?.[carrier.name] !== carrier.version || + native?.runtimeProduct !== sdk?.runtimeProduct || + native?.runtimeVersion !== sdk?.runtimeVersion || + native?.addonAbiVersion !== sdk?.wasixAddonAbiVersion || + native?.nodeApiVersion !== sdk?.nodeApiVersion || + JSON.stringify(native?.profiles) !== JSON.stringify(['standard', 'icu']) + ) { + fail( + `${carrier.name}@${carrier.version} is incompatible with ${PACKAGE_NAME}@${manifest.version}: SDK requires N-API ${sdk?.wasixNapiVersion} embedding runtime ${sdk?.runtimeVersion}, carrier embeds runtime ${native?.runtimeVersion}`, + ); + } +} + export function prepareWasixTypescriptPackage(packageDir) { const root = path.resolve(packageDir); const manifest = prepareProductPackage(root); diff --git a/src/wasix/sdks/ts/tools/wasix-typescript-package.test.mts b/src/wasix/sdks/ts/tools/wasix-typescript-package.test.mts index b424274a6..271d3d7b4 100644 --- a/src/wasix/sdks/ts/tools/wasix-typescript-package.test.mts +++ b/src/wasix/sdks/ts/tools/wasix-typescript-package.test.mts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { assertWasixTypescriptManifest } from './wasix-typescript-package.mts'; +import { + assertWasixTypescriptManifest, + assertWasixTypescriptNativeCarrier, +} from './wasix-typescript-package.mts'; function manifest() { return { @@ -40,6 +43,28 @@ function manifest() { } describe('WASIX TypeScript package dependency contract', () => { + test('checks the embedded runtime of every independently versioned native carrier', () => { + const sdk = manifest(); + for (const [name, version] of Object.entries(sdk.optionalDependencies)) { + const carrier = { + name, + version, + oliphaunt: { + runtimeProduct: sdk.oliphaunt.runtimeProduct, + runtimeVersion: sdk.oliphaunt.runtimeVersion, + addonAbiVersion: sdk.oliphaunt.wasixAddonAbiVersion, + nodeApiVersion: sdk.oliphaunt.nodeApiVersion, + profiles: ['standard', 'icu'], + }, + }; + expect(() => assertWasixTypescriptNativeCarrier(sdk, carrier)).not.toThrow(); + carrier.oliphaunt.runtimeVersion = '1.0.0'; + expect(() => assertWasixTypescriptNativeCarrier(sdk, carrier)).toThrow( + /carrier embeds runtime 1\.0\.0/u, + ); + } + }); + test('accepts the portable browser runtime and exact native platform carriers', () => { expect(() => assertWasixTypescriptManifest(manifest())).not.toThrow(); }); diff --git a/tools/release/check-release-intent.test.sh b/tools/release/check-release-intent.test.sh index 0b36d9d56..8185944a7 100644 --- a/tools/release/check-release-intent.test.sh +++ b/tools/release/check-release-intent.test.sh @@ -28,3 +28,71 @@ reject 'base must resolve to the exact commit parent' HEAD^ "$first" main workfl reject 'is not an ancestor' "$first" "$sibling" feature push refs/heads/feature reject 'requires matching main branch and full ref' HEAD "$first" main workflow_dispatch refs/heads/diagnostic echo 'Release intent exact-parent and ancestry checks passed' + +# Exercise both generated-PR and main-merge admission without live registries. +fixture="$scratch/release" +mkdir -p "$fixture/.github/scripts" "$fixture/tools/release" "$fixture/tools/dev" +cp "$script" .github/scripts/release-intent-data.mts "$fixture/.github/scripts/" +git -C "$fixture" init -q +git -C "$fixture" config user.name Fixture +git -C "$fixture" config user.email fixture@example.invalid +printf '%s' '{"changelog-sections":[{"type":"fix"}]}' > "$fixture/release-please-config.json" +printf '%s' '{"sdk":"0.2.1"}' > "$fixture/.release-please-manifest.json" +git -C "$fixture" add . +git -C "$fixture" commit -qm 'fix: initial SDK' +base="$(git -C "$fixture" rev-parse HEAD)" +printf '%s' '{"sdk":"0.2.2"}' > "$fixture/.release-please-manifest.json" +git -C "$fixture" commit -qam 'chore(release): fixture' +cat > "$fixture/tools/release/release-please-state.sh" <<'SH' +shift 2 +exec "$@" +SH +cp "$fixture/tools/release/release-please-state.sh" "$fixture/tools/release/with-release-history.sh" +cat > "$fixture/tools/release/with-product-history.sh" <<'SH' +[[ "$1" == "$PWD" && "$2" == HEAD && "$3" == '' && "$4" == @workspace ]] || exit 8 +shift 4 +exec "$@" +SH +cat > "$fixture/tools/dev/bun.sh" <<'SH' +#!/usr/bin/env bash +case "$1:${2:-}" in + tools/release/verify-release-commit.mts:--derive-products) echo '["oliphaunt-wasix-ts"]' ;; + tools/release/verify-release-commit.mts:--products-json) exit 0 ;; + tools/release/consumer-compatibility.mts:*) + [[ "$2" == '["oliphaunt-wasix-ts"]' ]] || exit 8 + printf '%s\n' "$2" > "$CI_TEST_CONSUMER_CALL" + exit "$CI_TEST_CONSUMER_STATUS" ;; + *) exit 8 ;; +esac +SH +chmod +x "$fixture/tools/dev/bun.sh" +cat > "$fixture/tools/release/release-plan.sh" <<'SH' +touch "$CI_TEST_PLAN_CALL" +echo '{"releaseProducts":["oliphaunt-wasix-ts"]}' +SH +export CI_TEST_CONSUMER_CALL="$scratch/consumer-call" CI_TEST_PLAN_CALL="$scratch/plan-call" +for branch in release-please--branches--main main; do + rm -f "$CI_TEST_PLAN_CALL" "$CI_TEST_CONSUMER_CALL" + status=0 + (cd "$fixture"; CI_TEST_CONSUMER_STATUS=9 bash .github/scripts/check-release-intent.sh \ + 'chore(release): fixture' "$base" HEAD "$branch") || status=$? + [[ "$status" == 9 && -s "$CI_TEST_CONSUMER_CALL" && ! -e "$CI_TEST_PLAN_CALL" ]] + (cd "$fixture"; CI_TEST_CONSUMER_STATUS=0 bash .github/scripts/check-release-intent.sh \ + 'chore(release): fixture' "$base" HEAD "$branch") + [[ -s "$CI_TEST_CONSUMER_CALL" && -e "$CI_TEST_PLAN_CALL" ]] +done +echo 'Release intent: consumer rejection blocks both generated PR and main release qualification' +base="$(git -C "$fixture" rev-parse HEAD)" +git -C "$fixture" commit --allow-empty -qm 'fix: publication controller' +rm -f "$CI_TEST_PLAN_CALL" "$CI_TEST_CONSUMER_CALL" +status=0 +(cd "$fixture"; CI_TEST_CONSUMER_STATUS=9 CI_RELEASE_PRODUCTS_JSON='["oliphaunt-wasix-ts"]' \ + bash .github/scripts/check-release-intent.sh 'fix: publication controller' \ + "$base" HEAD main workflow_dispatch refs/heads/main) || status=$? +[[ "$status" == 9 && -s "$CI_TEST_CONSUMER_CALL" && ! -e "$CI_TEST_PLAN_CALL" ]] +rm -f "$CI_TEST_CONSUMER_CALL" +(cd "$fixture"; CI_TEST_CONSUMER_STATUS=9 CI_RELEASE_PRODUCTS_JSON='["oliphaunt-wasix-ts"]' \ + bash .github/scripts/check-release-intent.sh 'fix: diagnostic source' \ + "$base" HEAD diagnostic workflow_dispatch refs/heads/diagnostic) +[[ ! -e "$CI_TEST_CONSUMER_CALL" && -e "$CI_TEST_PLAN_CALL" ]] +echo 'Release intent: selected main dispatch validates consumers; feature diagnostics retain source checks' diff --git a/tools/release/check-release-metadata.mts b/tools/release/check-release-metadata.mts index 9703b6324..7349d4a42 100755 --- a/tools/release/check-release-metadata.mts +++ b/tools/release/check-release-metadata.mts @@ -12,6 +12,7 @@ import { requireCompatibilityVersionBounds, requireMatchingWasixRuntime, } from './compatibility-version-policy.mts'; +import { validateConsumerContractCoverage } from './consumer-compatibility.mts'; import { declaredCarrierMap, loadPublicationCatalog, @@ -289,6 +290,7 @@ function validateReleasePleaseVersions(graph) { } function validateCompatibility(graph, { publication = false } = {}) { + validateConsumerContractCoverage(graph.products); const entries = compatibilityVersionEntries(graph.products, { requireSourceProduct: true, prefix: TOOL, diff --git a/tools/release/check-release-versions.sh b/tools/release/check-release-versions.sh index 84c8c3f4d..d5f94b27b 100644 --- a/tools/release/check-release-versions.sh +++ b/tools/release/check-release-versions.sh @@ -13,4 +13,4 @@ for ((index=0; index<${#args[@]}; index++)); do done RELEASE_HEAD_COMMIT=$(git rev-parse --verify --end-of-options "$head_ref^{commit}") export RELEASE_HEAD_COMMIT -bash tools/release/with-release-tags.sh bash tools/dev/bun.sh tools/release/check_release_versions.mts "$@" +bash tools/release/with-release-tags.sh bash tools/release/with-product-history.sh "$PWD" "$head_ref" '' @workspace bash tools/dev/bun.sh tools/release/check_release_versions.mts "$@" diff --git a/tools/release/check_registry_publication.mts b/tools/release/check_registry_publication.mts index 148679ee1..06ec84595 100644 --- a/tools/release/check_registry_publication.mts +++ b/tools/release/check_registry_publication.mts @@ -373,7 +373,11 @@ export async function productRegistryPackages( return filtered; } -async function requestJson(url, label, { fetchImpl = fetch } = {}) { +async function requestJson( + url, + label, + { fetchImpl = fetch, retryDelayImpl = registryRetryDelaySeconds } = {}, +) { let lastError; for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt += 1) { let retryHeaders; @@ -410,7 +414,7 @@ async function requestJson(url, label, { fetchImpl = fetch } = {}) { } if (attempt + 1 < REQUEST_ATTEMPTS) { await boundedRegistrySleep( - registryRetryDelaySeconds({ + retryDelayImpl({ headers: retryHeaders, attempt, baseSeconds: REQUEST_RETRY_DELAY_SECONDS, @@ -606,6 +610,28 @@ export async function npmPublishedVersion(packageName, version) { return data?.versions?.[version]; } +export async function cargoPublishedDependencies(packageName, version) { + await boundedRegistrySleep( + CRATES_IO_READ_INTERVAL_SECONDS, + 'published Cargo consumer dependencies', + ); + const url = `${CRATES_IO_API.replace(/\/+$/u, '')}/crates/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}/dependencies`; + const data = await requestJson(url, `${packageName}@${version} dependencies`, { + retryDelayImpl: cratesIoReadRetryDelaySeconds, + }); + if ( + !Array.isArray(data?.dependencies) || + data.dependencies.some( + (dependency) => typeof dependency.crate_id !== 'string' || typeof dependency.req !== 'string', + ) + ) { + throw new RegistryResponseError( + `${packageName}@${version} returned invalid Cargo dependencies`, + ); + } + return data.dependencies; +} + async function npmVersionExists(packageName, version) { return (await npmPublishedVersion(packageName, version)) !== undefined; } diff --git a/tools/release/check_release_versions.mts b/tools/release/check_release_versions.mts index 9ff167344..d982bd648 100644 --- a/tools/release/check_release_versions.mts +++ b/tools/release/check_release_versions.mts @@ -14,6 +14,10 @@ import { loadProducts, ROOT, } from './release-graph.mts'; +import { + validatePublishedCargoExtensionConsumers, + validateReleaseConsumerCompatibility, +} from './consumer-compatibility.mts'; import { checkRegistryPublication, queryProductPublication, @@ -308,6 +312,7 @@ export function selectedDependencySatisfiesPin( } async function validateReleaseDependencies(products, graph) { + validateReleaseConsumerCompatibility(products, { products: graph.products, prefix: TOOL }); const selected = new Set(products); const entries = compatibilityVersionEntries(graph.products, { requireSourceProduct: true, @@ -405,6 +410,8 @@ async function main(argv) { ); } await validateReleaseDependencies(selected, graph); + if (args.checkRegistries) + await validatePublishedCargoExtensionConsumers(selected, { products: graph.products }); if (args.checkRegistries) { const inventory = await validateRegistryPublication( selected, diff --git a/tools/release/close-release-candidate.sh b/tools/release/close-release-candidate.sh index 533106fd1..5756a94a6 100644 --- a/tools/release/close-release-candidate.sh +++ b/tools/release/close-release-candidate.sh @@ -18,4 +18,5 @@ if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then fi products="$(bash tools/release/release-please-state.sh "$PWD" HEAD bash tools/release/with-release-history.sh "$PWD" HEAD tools/dev/bun.sh tools/release/verify-release-commit.mts --derive-products --head-ref HEAD)" bash tools/release/release-please-state.sh "$PWD" HEAD bash tools/release/with-release-history.sh "$PWD" HEAD tools/dev/bun.sh tools/release/verify-release-commit.mts --products-json "$products" --head-ref HEAD +bash tools/release/with-product-history.sh "$PWD" HEAD '' @workspace bash tools/dev/bun.sh tools/release/consumer-compatibility.mts "$products" bash tools/release/release-metadata-check.sh --publication diff --git a/tools/release/consumer-compatibility.mts b/tools/release/consumer-compatibility.mts new file mode 100644 index 000000000..340f7310e --- /dev/null +++ b/tools/release/consumer-compatibility.mts @@ -0,0 +1,160 @@ +import { loadProducts, productCompatibilityVersion } from './release-graph.mts'; +import { requireMatchingWasixRuntime } from './compatibility-version-policy.mts'; +import { cargoPublishedDependencies } from './check_registry_publication.mts'; + +// These consumers enforce exact runtime identity when loading extension carriers. +// Independent packaging versions do not establish cross-runtime compatibility. +const EXTENSION_CONSUMERS = [ + ['oliphaunt-js', 'liboliphaunt-native'], + ['oliphaunt-kotlin', 'liboliphaunt-native'], + ['oliphaunt-swift', 'liboliphaunt-native'], + ['oliphaunt-wasix-rust', 'liboliphaunt-wasix'], + ['oliphaunt-wasix-ts', 'liboliphaunt-wasix'], +]; + +export function validateConsumerContractCoverage(products) { + const known = new Set([...EXTENSION_CONSUMERS.map(([id]) => id), 'oliphaunt-rust']); + for (const [id, product] of Object.entries(products)) { + if ( + product.kind === 'sdk' && + Object.values(product.compatibility_versions ?? {}).some(({ source_product }) => + ['liboliphaunt-native', 'liboliphaunt-wasix'].includes(source_product), + ) && + !known.has(id) + ) { + throw new Error( + `release-consumer-compatibility: ${id} must declare its extension consumer compatibility policy`, + ); + } + } +} + +export function validateReleaseConsumerCompatibility( + selectedProducts, + { + products = loadProducts(), + readCompatibility = productCompatibilityVersion, + prefix = 'release-consumer-compatibility', + } = {}, +) { + validateConsumerContractCoverage(products); + const selected = new Set(selectedProducts); + for (const id of selected) { + if (!products[id]) throw new Error(`${prefix}: unknown release product ${id}`); + } + // An unselected product is the immutable published package, even if its + // workspace metadata has already changed in preparation for another release. + const pin = (product, source, version = products[product].version) => + readCompatibility(product, source, prefix, { + ref: + selected.has(product) && version === products[product].version + ? null + : products[product].tag_prefix + version, + }); + const extensions = Object.entries(products).filter( + ([, product]) => product.extension?.class === 'external', + ); + const consumers = EXTENSION_CONSUMERS.filter(([id]) => selected.has(id)).map( + ([product, runtime]) => ({ product, runtime, version: products[product].version }), + ); + for (const owner of selected) { + const dependencies = new Set( + Object.values(products[owner].compatibility_versions ?? {}).map( + ({ source_product }) => source_product, + ), + ); + for (const [dependency, runtime] of EXTENSION_CONSUMERS) { + if (dependencies.has(dependency)) { + consumers.push({ product: dependency, runtime, version: pin(owner, dependency), owner }); + } + } + } + const failures = []; + for (const { product, runtime, version, owner } of consumers) { + const runtimeVersion = pin(product, runtime, version); + const label = `${owner ? `${owner} through ` : ''}${product}@${version}`; + if ( + owner && + Object.values(products[owner].compatibility_versions ?? {}).some( + ({ source_product }) => source_product === runtime, + ) + ) { + const ownerRuntime = pin(owner, runtime); + if (ownerRuntime !== runtimeVersion) { + failures.push( + `${label} targets ${runtime}@${runtimeVersion}, but ${owner} targets ${runtime}@${ownerRuntime}`, + ); + } + } + if (product === 'oliphaunt-wasix-ts') { + const napiVersion = pin(product, 'oliphaunt-wasix-napi', version); + try { + requireMatchingWasixRuntime( + { + runtimeVersion, + napiVersion, + napiRuntimeVersion: pin('oliphaunt-wasix-napi', runtime, napiVersion), + }, + { prefix: label }, + ); + } catch (cause) { + failures.push(cause.message); + } + } + for (const [extension, metadata] of extensions) { + const extensionRuntime = pin(extension, runtime); + if (extensionRuntime !== runtimeVersion) { + failures.push( + `${label} requires ${runtime}@${runtimeVersion}, but ${extension}@${metadata.version} targets ${runtime}@${extensionRuntime}`, + ); + } + } + } + if (failures.length > 0) { + throw new Error( + `${prefix}: release consumers are incompatible:\n${[...new Set(failures)].join('\n')}\nPrepare matching independently versioned packages; workspace qualification fixtures cannot prove this release combination.`, + ); + } +} + +export async function validatePublishedCargoExtensionConsumers( + selectedProducts, + { products = loadProducts(), readDependencies = cargoPublishedDependencies } = {}, +) { + const selected = new Set(selectedProducts); + const requirements = []; + if (selected.has('oliphaunt-rust')) { + requirements.push(['oliphaunt-build', products['oliphaunt-rust'].version]); + } + if (selected.has('oliphaunt-wasix-rust')) { + requirements.push(['oliphaunt-wasix', products['oliphaunt-wasix-rust'].version]); + } + if (requirements.length === 0) return; + const failures = []; + for (const [extension, metadata] of Object.entries(products)) { + if (metadata.extension?.class !== 'external' || selected.has(extension)) continue; + const dependencies = await readDependencies(extension, metadata.version); + for (const [name, version] of requirements) { + const matches = dependencies.filter(({ crate_id }) => crate_id === name); + if (matches.length === 0 || matches.some(({ req }) => !Bun.semver.satisfies(version, req))) { + failures.push( + `${extension}@${metadata.version} requires ${name} ${matches.map(({ req }) => req).join(', ') || ''}, incompatible with the selected SDK API ${name}@${version}`, + ); + } + } + } + if (failures.length > 0) { + throw new Error( + `release-consumer-compatibility: published Cargo extensions cannot resolve with the selected SDK:\n${failures.join('\n')}`, + ); + } +} + +if (import.meta.main) { + const selected = JSON.parse(process.argv[2] ?? 'null'); + if (!Array.isArray(selected) || selected.some((id) => typeof id !== 'string')) { + throw new Error('usage: consumer-compatibility.mts PRODUCTS_JSON'); + } + validateReleaseConsumerCompatibility(selected); + await validatePublishedCargoExtensionConsumers(selected); +} diff --git a/tools/release/consumer-compatibility.test.mts b/tools/release/consumer-compatibility.test.mts new file mode 100644 index 000000000..7e567977e --- /dev/null +++ b/tools/release/consumer-compatibility.test.mts @@ -0,0 +1,198 @@ +import { expect, test } from 'bun:test'; +import { + validatePublishedCargoExtensionConsumers, + validateReleaseConsumerCompatibility, + validateConsumerContractCoverage, +} from './consumer-compatibility.mts'; + +function fixture() { + const versions = { + 'oliphaunt-wasix-ts': '0.2.1', + 'oliphaunt-wasix-napi': '0.2.0', + 'postgres-tools-wasix': '0.2.3', + 'oliphaunt-wasix-rust': '0.3.1', + 'oliphaunt-rust': '0.3.1', + 'oliphaunt-js': '0.3.0', + 'oliphaunt-kotlin': '0.3.1', + 'oliphaunt-swift': '0.8.0', + 'oliphaunt-react-native': '0.3.0', + 'oliphaunt-extension-vector': '9.8.7', + 'liboliphaunt-wasix': '0.3.1', + 'liboliphaunt-native': '0.3.1', + }; + const products = Object.fromEntries( + Object.entries(versions).map(([id, version]) => [ + id, + { + version, + tag_prefix: `${id}-v`, + ...(id === 'oliphaunt-extension-vector' ? { extension: { class: 'external' } } : {}), + }, + ]), + ); + const pins = { + 'oliphaunt-wasix-ts': { 'liboliphaunt-wasix': '0.3.1', 'oliphaunt-wasix-napi': '0.2.0' }, + 'oliphaunt-wasix-napi': { 'liboliphaunt-wasix': '0.3.1' }, + 'oliphaunt-wasix-rust': { 'liboliphaunt-wasix': '0.3.1' }, + 'postgres-tools-wasix': { + 'oliphaunt-wasix-ts': '0.2.1', + 'liboliphaunt-wasix': '0.3.1', + }, + 'oliphaunt-js': { 'liboliphaunt-native': '0.3.1' }, + 'oliphaunt-kotlin': { 'liboliphaunt-native': '0.3.1' }, + 'oliphaunt-swift': { 'liboliphaunt-native': '0.3.1' }, + 'oliphaunt-react-native': { 'oliphaunt-kotlin': '0.3.1', 'oliphaunt-swift': '0.8.0' }, + 'oliphaunt-extension-vector': { 'liboliphaunt-wasix': '0.3.1', 'liboliphaunt-native': '0.3.1' }, + }; + const published = structuredClone(pins); + for (const [id, fields] of Object.entries(pins)) { + products[id].compatibility_versions = Object.fromEntries( + Object.keys(fields).map((source) => [source, { source_product: source }]), + ); + } + const reads = []; + const readCompatibility = (product, source, _prefix, { ref }) => { + reads.push({ product, source, ref }); + if (ref !== null) expect(ref.startsWith(products[product].tag_prefix)).toBe(true); + return (ref === null ? pins : published)[product][source]; + }; + return { products, pins, published, reads, readCompatibility }; +} + +test('compatible independent products pass without coupling their packaging versions', () => { + const state = fixture(); + expect(() => + validateReleaseConsumerCompatibility(['oliphaunt-wasix-ts', 'oliphaunt-kotlin'], state), + ).not.toThrow(); +}); + +test('source qualification cannot substitute a newer workspace addon for a published carrier', () => { + const state = fixture(); + state.published['oliphaunt-wasix-napi']['liboliphaunt-wasix'] = '0.3.0'; + expect(() => validateReleaseConsumerCompatibility(['oliphaunt-wasix-ts'], state)).toThrow( + 'runtime 0.3.1 differs', + ); + expect(() => + validateReleaseConsumerCompatibility(['oliphaunt-wasix-ts', 'oliphaunt-wasix-napi'], state), + ).not.toThrow(); +}); + +test('historical extension metadata must load with every selected consumer runtime', () => { + for (const product of [ + 'oliphaunt-wasix-ts', + 'oliphaunt-wasix-rust', + 'oliphaunt-js', + 'oliphaunt-kotlin', + 'oliphaunt-swift', + ]) { + const state = fixture(); + state.published['oliphaunt-extension-vector'] = { + 'liboliphaunt-wasix': '0.3.0', + 'liboliphaunt-native': '0.3.0', + }; + expect(() => validateReleaseConsumerCompatibility([product], state)).toThrow( + 'vector@9.8.7 targets', + ); + expect(() => + validateReleaseConsumerCompatibility([product, 'oliphaunt-extension-vector'], state), + ).not.toThrow(); + } +}); + +test('tools and React Native validate the historical SDKs they actually pin', () => { + for (const [owner, dependency, source] of [ + ['postgres-tools-wasix', 'oliphaunt-wasix-ts', 'liboliphaunt-wasix'], + ['oliphaunt-react-native', 'oliphaunt-kotlin', 'liboliphaunt-native'], + ['oliphaunt-react-native', 'oliphaunt-swift', 'liboliphaunt-native'], + ]) { + const state = fixture(); + state.published[dependency][source] = '0.3.0'; + expect(() => validateReleaseConsumerCompatibility([owner], state)).toThrow( + `${owner} through ${dependency}`, + ); + } +}); + +test('an unrelated runtime or extension release retains independent consumer boundaries', () => { + const state = fixture(); + state.published['oliphaunt-wasix-napi']['liboliphaunt-wasix'] = '0.3.0'; + expect(() => + validateReleaseConsumerCompatibility( + ['liboliphaunt-wasix', 'oliphaunt-extension-vector'], + state, + ), + ).not.toThrow(); + expect(state.reads).toEqual([]); +}); + +test('a tools-only release uses its older SDK tag after the workspace SDK advances', () => { + const state = fixture(); + state.products['oliphaunt-wasix-ts'].version = '0.2.2'; + state.pins['oliphaunt-wasix-ts']['liboliphaunt-wasix'] = '0.3.2'; + expect(() => validateReleaseConsumerCompatibility(['postgres-tools-wasix'], state)).not.toThrow(); + expect(state.reads).toContainEqual({ + product: 'oliphaunt-wasix-ts', + source: 'liboliphaunt-wasix', + ref: 'oliphaunt-wasix-ts-v0.2.1', + }); +}); + +test('tools and embedded SDK consumers must agree with their pinned SDK runtime', () => { + const state = fixture(); + state.pins['postgres-tools-wasix']['liboliphaunt-wasix'] = '0.3.2'; + expect(() => validateReleaseConsumerCompatibility(['postgres-tools-wasix'], state)).toThrow( + 'postgres-tools-wasix targets liboliphaunt-wasix@0.3.2', + ); +}); + +test('Cargo SDK release admission rejects exact historical facade API pins and accepts compatible ranges', async () => { + const state = fixture(); + const dependencies = [ + { crate_id: 'oliphaunt-build', req: '=0.3.0' }, + { crate_id: 'oliphaunt-wasix', req: '=0.3.0' }, + ]; + const options = { products: state.products, readDependencies: async () => dependencies }; + await expect( + validatePublishedCargoExtensionConsumers(['oliphaunt-rust', 'oliphaunt-wasix-rust'], options), + ).rejects.toThrow('cannot resolve'); + for (const row of dependencies) row.req = '^0.3.0'; + await expect( + validatePublishedCargoExtensionConsumers(['oliphaunt-rust', 'oliphaunt-wasix-rust'], options), + ).resolves.toBeUndefined(); + dependencies[0].req = '^0.4.0'; + await expect( + validatePublishedCargoExtensionConsumers(['oliphaunt-rust'], options), + ).rejects.toThrow('incompatible'); + await expect( + validatePublishedCargoExtensionConsumers( + ['oliphaunt-rust', 'oliphaunt-extension-vector'], + options, + ), + ).resolves.toBeUndefined(); +}); + +test('missing facade APIs fail closed and unrelated releases make no Cargo requests', async () => { + const state = fixture(); + let calls = 0; + const options = { + products: state.products, + readDependencies: async () => { + calls += 1; + return []; + }, + }; + await expect( + validatePublishedCargoExtensionConsumers(['oliphaunt-rust'], options), + ).rejects.toThrow(''); + await validatePublishedCargoExtensionConsumers(['oliphaunt-js'], options); + expect(calls).toBe(1); +}); + +test('new runtime SDKs require an explicit consumer contract before metadata admission', () => { + const state = fixture(); + state.products['future-sdk'] = { + kind: 'sdk', + compatibility_versions: { runtime: { source_product: 'liboliphaunt-wasix' } }, + }; + expect(() => validateConsumerContractCoverage(state.products)).toThrow('future-sdk must declare'); +}); diff --git a/tools/release/independent-version-pins.test.mts b/tools/release/independent-version-pins.test.mts index 12fe40b26..320ce402f 100644 --- a/tools/release/independent-version-pins.test.mts +++ b/tools/release/independent-version-pins.test.mts @@ -32,6 +32,7 @@ import { import { assertWasixNapiCarrierManifest } from '../../src/wasix/node-addon/tools/check-release-assets.mts'; import { requireMatchingWasixRuntime } from './compatibility-version-policy.mts'; import { workspaceBindingManifest } from '../../src/wasix/sdks/ts/tools/integration/packed-node-fixture.mts'; +import { prepareWasixToolsTypescriptPackage } from '../../src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.mts'; if (process.env.OLIPHAUNT_INDEPENDENT_VERSION_TEST !== '1' || existsSync(path.join(ROOT, '.git'))) { throw new Error('Run bash tools/release/independent-version-pins.test.sh'); @@ -157,6 +158,24 @@ test('WASIX TypeScript npm staging uses its portable runtime and Node-API pins', } }); +test('WASIX tools staging retains its explicit SDK pin after that SDK advances', () => { + const source = 'src/wasix/postgres-tools/ts/package.json'; + const original = json(source); + const sdkFile = 'src/wasix/sdks/ts/package.json'; + const sdk = read(sdkFile); + try { + write(sdkFile, JSON.stringify({ ...JSON.parse(sdk), version: '99.0.0' })); + const staged = prepareWasixToolsTypescriptPackage(stageManifest('wasix-tools', source)); + assert.equal( + staged.peerDependencies['@oliphaunt/wasix-ts'], + original.oliphaunt.wasixSdkVersion, + ); + assert.deepEqual(staged.oliphaunt, original.oliphaunt); + } finally { + write(sdkFile, sdk); + } +}); + test('WASIX addon qualification identifies the compiled workspace runtime without changing release pins', () => { const product = json('src/wasix/node-addon/package.json'); const original = json('src/wasix/node-addon/packages/linux-x64-gnu/package.json'); diff --git a/tools/release/moon.yml b/tools/release/moon.yml index 9eccafe00..5eaeab044 100644 --- a/tools/release/moon.yml +++ b/tools/release/moon.yml @@ -122,6 +122,11 @@ tasks: - requires-rust command: bash tools/release/release-check.sh --mutation-tests-only inputs: + - /src/wasix/sdks/ts/package.json + - /src/wasix/sdks/ts/tools/wasix-typescript-package.mts + - /src/wasix/sdks/ts/tools/package.mts + - /src/wasix/postgres-tools/ts/tools/package.mts + - /src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.mts - /src/wasix/runtime/tools/download-assets.sh - /src/wasix/runtime/tools/wasix-cargo-artifact-contract.mts - /tools/packaging/**/* @@ -171,6 +176,8 @@ tasks: - /src/wasix/sdks/ts/tools/package.mts - /src/wasix/sdks/ts/tools/wasix-typescript-package.mts - /src/wasix/sdks/ts/tools/integration/packed-node-fixture.mts + - /src/wasix/postgres-tools/ts/tools/package.mts + - /src/wasix/postgres-tools/ts/tools/wasix-tools-typescript-package.mts - /src/wasix/node-addon/tools/workspace-runtime-contract.mts - /src/wasix/node-addon/tools/native-build-data.mts - /src/wasix/node-addon/tools/check-release-assets.mts diff --git a/tools/release/prepare-release-candidate.test.mts b/tools/release/prepare-release-candidate.test.mts index 1f4b80459..29f7c508a 100644 --- a/tools/release/prepare-release-candidate.test.mts +++ b/tools/release/prepare-release-candidate.test.mts @@ -27,6 +27,17 @@ const first = 'a'.repeat(40); const second = 'b'.repeat(40); const head = 'c'.repeat(40); +test('extension facade generator changes select every product shipping its output', () => { + const products = Object.entries(graph.products) + .filter(([, product]) => product.extension?.class === 'external') + .map(([id]) => id); + expect( + buildPlan(graph, [ + 'src/extensions/artifacts/packages/tools/package-extension-cargo-facades.mts', + ]).releaseProducts.sort(), + ).toEqual(['liboliphaunt-native', ...products].sort()); +}); + test('every binary release includes its compiled sources across product boundaries', () => { const mobile = ['oliphaunt-swift', 'oliphaunt-kotlin']; for (const [file, products] of [ diff --git a/tools/release/public-consumer-smoke.mts b/tools/release/public-consumer-smoke.mts index 13e6f2f33..4cbe20bf2 100644 --- a/tools/release/public-consumer-smoke.mts +++ b/tools/release/public-consumer-smoke.mts @@ -13,6 +13,8 @@ import { } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { createRequire } from 'node:module'; +import { assertWasixTypescriptNativeCarrier } from '../../src/wasix/sdks/ts/tools/wasix-typescript-package.mts'; import { EXTENSION_PORTABLE_TARGET } from '../../src/wasix/runtime/tools/wasix-cargo-artifact-contract.mts'; import { DEFAULT_PUBLICATION_LOCK, loadPublicationLock } from './publication-lock.mts'; import { registryRetryDelaySeconds, registryStatusRetryable } from './registry-http-retry.mts'; @@ -901,6 +903,24 @@ export function validateNpmResolution(packageLock, carriers, requiredEntryIds, n } resolved.push({ id: carrier.id, version: carrier.version, integrity: row.integrity }); } + // Historical SDK dependencies are also consumers, even when not being published. + for (const key of Object.keys(packages)) { + if (!/(^|\/)node_modules\/@oliphaunt\/wasix-ts$/u.test(key)) continue; + if (path.isAbsolute(key) || key.split('/').includes('..')) { + throw error('installed WASIX SDK path escapes the clean npm consumer'); + } + const manifestFile = path.join(nodeModules, key, 'package.json'); + const installed = JSON.parse(readFileSync(manifestFile, 'utf8')); + const target = Object.values(DESKTOP_TARGETS).find( + ({ npmOs, npmCpu }) => npmOs === process.platform && npmCpu === process.arch, + ); + if (!target?.wasixNapiPackage) { + throw error(`no WASIX N-API public consumer target for ${process.platform}/${process.arch}`); + } + const require = createRequire(manifestFile); + const nativeFile = require.resolve(`${target.wasixNapiPackage}/package.json`); + assertWasixTypescriptNativeCarrier(installed, JSON.parse(readFileSync(nativeFile, 'utf8'))); + } resolved.sort((left, right) => compareText(left.id, right.id)); const installedCarrierIds = carriers .filter((carrier) => { diff --git a/tools/release/public-consumer-smoke.test.mts b/tools/release/public-consumer-smoke.test.mts index 2b076fa60..d5a8b31ea 100644 --- a/tools/release/public-consumer-smoke.test.mts +++ b/tools/release/public-consumer-smoke.test.mts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { DESKTOP_TARGETS } from './release-artifact-targets.mts'; import { cargoEntryFeatureNames, @@ -426,6 +427,99 @@ test('fails closed on product, target, carrier, and dependency-closure omissions ); }); +test('clean WASIX SDK installs reject older N-API runtimes outside the selected publication lock', () => { + const root = mkdtempSync(path.join(tmpdir(), 'oliphaunt-public-wasix-test-')); + try { + const target = Object.values(DESKTOP_TARGETS).find( + ({ npmOs, npmCpu }) => npmOs === process.platform && npmCpu === process.arch, + ); + const name = target.wasixNapiPackage; + const sdk = { + ...JSON.parse(readFileSync('src/wasix/sdks/ts/package.json', 'utf8')), + version: '0.2.1', + oliphaunt: { + runtimeProduct: 'liboliphaunt-wasix', + runtimeVersion: '0.3.1', + wasixNapiProduct: 'oliphaunt-wasix-napi', + wasixNapiVersion: '0.2.0', + wasixAddonAbiVersion: 3, + nodeApiVersion: 8, + browserHost: 'wasmer-js-patched', + serverHost: 'wasix-rust-napi', + }, + }; + delete sdk.scripts; + delete sdk.devDependencies; + sdk.dependencies['@oliphaunt/liboliphaunt-wasix'] = '0.3.1'; + sdk.optionalDependencies = Object.fromEntries( + Object.keys(sdk.optionalDependencies).map((name) => [name, '0.2.0']), + ); + const sdkRoot = path.join(root, 'node_modules/@oliphaunt/wasix-ts'); + // npm may nest the carrier under the SDK rather than hoisting it. + const nativeRoot = path.join(sdkRoot, 'node_modules', name); + mkdirSync(nativeRoot, { recursive: true }); + writeFileSync(path.join(sdkRoot, 'package.json'), JSON.stringify(sdk)); + const native = { + name, + version: '0.2.0', + exports: { './package.json': './package.json' }, + oliphaunt: { + runtimeProduct: 'liboliphaunt-wasix', + runtimeVersion: '0.3.0', + addonAbiVersion: 3, + nodeApiVersion: 8, + profiles: ['standard', 'icu'], + }, + }; + const nativeFile = path.join(nativeRoot, 'package.json'); + writeFileSync(nativeFile, JSON.stringify(native)); + const entry = { + ...carrier('npm:@oliphaunt/wasix-ts', 'oliphaunt-wasix-ts', 0), + version: sdk.version, + }; + const packageLock = { + lockfileVersion: 3, + packages: { + 'node_modules/@oliphaunt/wasix-ts': { + version: sdk.version, + resolved: 'https://registry.npmjs.org/@oliphaunt/wasix-ts/-/wasix-ts-0.2.1.tgz', + integrity: 'sha512-exact', + }, + }, + }; + const validate = () => validateNpmResolution(packageLock, [entry], [entry.id], root); + assert.throws( + validate, + /SDK requires N-API 0\.2\.0 embedding runtime 0\.3\.1, carrier embeds runtime 0\.3\.0/u, + ); + const tools = carrier('npm:@oliphaunt/wasix-tools', 'postgres-tools-wasix', 0); + const toolsRoot = path.join(root, 'node_modules', '@oliphaunt', 'wasix-tools'); + mkdirSync(toolsRoot, { recursive: true }); + writeFileSync( + path.join(toolsRoot, 'package.json'), + JSON.stringify({ name: tools.name, version: tools.version }), + ); + packageLock.packages['node_modules/@oliphaunt/wasix-tools'] = { + version: tools.version, + resolved: 'https://registry.npmjs.org/@oliphaunt/wasix-tools/-/wasix-tools.tgz', + integrity: 'sha512-exact', + }; + const validateTools = () => validateNpmResolution(packageLock, [tools], [tools.id], root); + assert.throws(validateTools, /carrier embeds runtime 0\.3\.0/u); + native.oliphaunt.runtimeVersion = '0.3.1'; + writeFileSync(nativeFile, JSON.stringify(native)); + assert.doesNotThrow(validate); + assert.doesNotThrow(validateTools); + native.version = '0.2.2'; + writeFileSync(nativeFile, JSON.stringify(native)); + assert.throws(validate, /incompatible with @oliphaunt\/wasix-ts@0\.2\.1/u); + rmSync(nativeFile); + assert.throws(validate, /Cannot find module|ModuleNotFound|ENOENT/u); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('validates exact public Cargo, npm, and Maven resolution records', () => { const cargo = [carrier('cargo:alpha', 'alpha', 0)]; assert.deepEqual( diff --git a/tools/release/publication-lock.mts b/tools/release/publication-lock.mts index 50eb095e3..acc164ff6 100644 --- a/tools/release/publication-lock.mts +++ b/tools/release/publication-lock.mts @@ -25,6 +25,11 @@ import { validateSwiftSourceReleaseContract, } from '../../src/native/sdks/swift/tools/swift-source-carrier-contract.mts'; import { releaseJavaScript } from '../packaging/emit-javascript.mts'; +import { + assertWasixTypescriptManifest, + assertWasixTypescriptNativeCarrier, +} from '../../src/wasix/sdks/ts/tools/wasix-typescript-package.mts'; +import { requireMatchingWasixRuntime } from './compatibility-version-policy.mts'; import { parseMavenArtifactManifest } from '../packaging/maven-artifact-manifest.mts'; import { validateMavenCentralPublication } from '../packaging/maven-central-contract.mts'; import { validateNpmTrustedPublishingManifest } from '../packaging/npm-trusted-publishing.mts'; @@ -47,7 +52,12 @@ import { extensionSourceIdentity, extensionSqlNames, } from './release-artifact-targets.mts'; -import { compareText, productCompatibilityVersion, ROOT } from './release-graph.mts'; +import { + compareText, + productCompatibilityVersion, + productDependencyCompatibilityVersion, + ROOT, +} from './release-graph.mts'; export { validateSelectionNeutralSwiftSourceCarrier }; @@ -2035,6 +2045,7 @@ export function buildPublicationCandidate({ fullCatalog, selectedProducts, ); + assertWasixPublicationPackages(artifacts); const productArtifacts = discoverProductArtifacts(artifactRoots, catalog.products); const carriers = []; const seenStableIds = new Set(); @@ -2103,6 +2114,58 @@ export function buildPublicationCandidate({ }; } +export function assertWasixPublicationPackages( + artifacts, + { + workspaceRoot = ROOT, + readCompatibility = productCompatibilityVersion, + readDependencyCompatibility = productDependencyCompatibilityVersion, + } = {}, +) { + const sdkArtifact = artifacts.find( + ({ ecosystem, name }) => ecosystem === 'npm' && name === '@oliphaunt/wasix-ts', + ); + if (sdkArtifact === undefined) return; + const manifest = (artifact) => + JSON.parse( + archiveMemberText( + path.resolve(workspaceRoot, artifact.artifacts[0].path), + 'package/package.json', + { + exact: true, + }, + ), + ); + const sdk = assertWasixTypescriptManifest(manifest(sdkArtifact)); + const runtimeVersion = readCompatibility('oliphaunt-wasix-ts', 'liboliphaunt-wasix'); + const napiVersion = readCompatibility('oliphaunt-wasix-ts', 'oliphaunt-wasix-napi'); + if ( + sdk.oliphaunt.runtimeVersion !== runtimeVersion || + sdk.oliphaunt.wasixNapiVersion !== napiVersion + ) { + throw error( + 'frozen WASIX TypeScript package differs from its declared release dependency pins', + ); + } + requireMatchingWasixRuntime({ + runtimeVersion, + napiVersion, + napiRuntimeVersion: readDependencyCompatibility( + 'oliphaunt-wasix-ts', + 'oliphaunt-wasix-napi', + 'liboliphaunt-wasix', + ), + }); + for (const artifact of artifacts) { + if ( + artifact.ecosystem !== 'npm' || + sdk.optionalDependencies[artifact.name] !== artifact.version + ) + continue; + assertWasixTypescriptNativeCarrier(sdk, manifest(artifact)); + } +} + function withoutDigest(value) { const copy = structuredClone(value); delete copy.lockDigest; @@ -2858,6 +2921,7 @@ export function lockedPublicationFiles(lock, { products, workspaceRoot = ROOT } files.set(filePath, envelope); } } + assertWasixPublicationPackages(lockedCarriers(lock, { products: selected }), { workspaceRoot }); return [...files.values()].sort((left, right) => compareText(left.path, right.path)); } diff --git a/tools/release/publication-lock.test.mts b/tools/release/publication-lock.test.mts index f825513d4..946b3a7b9 100644 --- a/tools/release/publication-lock.test.mts +++ b/tools/release/publication-lock.test.mts @@ -32,11 +32,13 @@ import { assertLockedArtifactSet, assertLockedProductArtifacts, assertPublicationLockSource, + assertWasixPublicationPackages, buildPublicationCandidate, discoverProductArtifacts, discoverPublicationArtifacts, freezePublicationCandidate, lockedCarrierFile, + lockedPublicationFiles, projectInternalDependencyIds, validateCargoPayloadPartSets, validatePublicationCandidate, @@ -51,7 +53,7 @@ import { extensionSourceIdentity, extensionSqlNames, } from './release-artifact-targets.mts'; -import { productCompatibilityVersion } from './release-graph.mts'; +import { productCompatibilityVersion, ROOT } from './release-graph.mts'; const temporaryDirectories = []; @@ -237,6 +239,89 @@ function npmFixture(root, name, version, overrides = {}, bundledManifest = null) return output; } +test('freezing pristine WASIX npm bytes checks all four carriers and historical pins', () => { + const root = temporaryDirectory(); + const sdk = JSON.parse( + readFileSync(new URL('../../src/wasix/sdks/ts/package.json', import.meta.url), 'utf8'), + ); + delete sdk.scripts; + delete sdk.devDependencies; + sdk.dependencies['@oliphaunt/liboliphaunt-wasix'] = sdk.oliphaunt.runtimeVersion; + sdk.dependencies['@oliphaunt/ts-query'] = productCompatibilityVersion( + 'oliphaunt-wasix-ts', + 'oliphaunt-query-ts', + ); + for (const name of Object.keys(sdk.optionalDependencies)) + sdk.optionalDependencies[name] = sdk.oliphaunt.wasixNapiVersion; + const options = { + readCompatibility: (_product, source) => + source === 'liboliphaunt-wasix' + ? sdk.oliphaunt.runtimeVersion + : sdk.oliphaunt.wasixNapiVersion, + readDependencyCompatibility: () => sdk.oliphaunt.runtimeVersion, + }; + npmFixture(path.join(root, 'sdk'), sdk.name, sdk.version, sdk); + for (const name of Object.keys(sdk.optionalDependencies)) { + npmFixture(path.join(root, name.split('/')[1]), name, sdk.oliphaunt.wasixNapiVersion, { + oliphaunt: { + runtimeProduct: sdk.oliphaunt.runtimeProduct, + runtimeVersion: sdk.oliphaunt.runtimeVersion, + addonAbiVersion: sdk.oliphaunt.wasixAddonAbiVersion, + nodeApiVersion: sdk.oliphaunt.nodeApiVersion, + profiles: ['standard', 'icu'], + }, + }); + } + const artifacts = () => discoverPublicationArtifacts([root]); + expect(() => assertWasixPublicationPackages(artifacts(), options)).not.toThrow(); + expect(() => + assertWasixPublicationPackages(artifacts(), { + ...options, + readDependencyCompatibility: () => '99.0.0', + }), + ).toThrow('select a new addon release'); + const name = '@oliphaunt/wasix-napi-darwin-arm64'; + npmFixture(path.join(root, name.split('/')[1]), name, sdk.oliphaunt.wasixNapiVersion, { + oliphaunt: { + runtimeProduct: sdk.oliphaunt.runtimeProduct, + runtimeVersion: '99.0.0', + addonAbiVersion: 3, + nodeApiVersion: 8, + profiles: ['standard', 'icu'], + }, + }); + expect(() => assertWasixPublicationPackages(artifacts(), options)).toThrow( + 'carrier embeds runtime 99.0.0', + ); + // A checksum-consistent historical capsule still needs consumer validation. + // The staged root differs from ROOT during atomic candidate extraction. + const reused = { + products: [{ id: 'oliphaunt-wasix-ts' }, { id: 'oliphaunt-wasix-napi' }], + carriers: artifacts().map((carrier) => ({ + ...carrier, + product: carrier.name === sdk.name ? 'oliphaunt-wasix-ts' : 'oliphaunt-wasix-napi', + artifacts: carrier.artifacts.map((artifact) => ({ + ...artifact, + path: path.relative(root, path.resolve(ROOT, artifact.path)), + })), + })), + productArtifacts: [], + }; + expect(() => lockedPublicationFiles(reused, { workspaceRoot: root })).toThrow( + /runtime .*differs|carrier embeds runtime 99.0.0/u, + ); + sdk.oliphaunt.runtimeVersion = '98.0.0'; + sdk.dependencies['@oliphaunt/liboliphaunt-wasix'] = '98.0.0'; + npmFixture(path.join(root, 'sdk'), sdk.name, sdk.version, sdk); + expect(() => + assertWasixPublicationPackages(artifacts(), { + ...options, + readCompatibility: (_product, source) => + source === 'liboliphaunt-wasix' ? '97.0.0' : sdk.oliphaunt.wasixNapiVersion, + }), + ).toThrow('differs from its declared release dependency pins'); +}); + function cargoFixture(root, name, version, { manifestSuffix = '' } = {}) { const directoryName = `${name}-${version}`; const stage = path.join(root, 'cargo-stage', directoryName); diff --git a/tools/release/release-history.mts b/tools/release/release-history.mts index 95a091ebb..812b8e7ec 100644 --- a/tools/release/release-history.mts +++ b/tools/release/release-history.mts @@ -20,8 +20,9 @@ function history(root) { export function historyCommit(root, ref, { check = true } = {}) { const { directory } = history(root); const refs = pairs(path.join(directory, 'refs')); - if (!refs.has(ref)) throw new Error('product history does not include ref ' + ref); - const commit = refs.get(ref); + const key = refs.has(ref) ? ref : `refs/tags/${ref}`; + if (!refs.has(key)) throw new Error('product history does not include ref ' + ref); + const commit = refs.get(key); if (!commit && check) throw new Error('could not resolve product ref ' + ref); return commit || null; } diff --git a/tools/release/release-history.test.mts b/tools/release/release-history.test.mts index 81f46f394..3e558a403 100644 --- a/tools/release/release-history.test.mts +++ b/tools/release/release-history.test.mts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { compatibilityVersionValue, productVersionTransitionStatus } from './release-graph.mts'; -import { historyFile, historyManifest } from './release-history.mts'; +import { historyCommit, historyFile, historyManifest } from './release-history.mts'; const [mode, repo] = process.argv.slice(2); if (mode === 'prepare') { @@ -32,6 +32,8 @@ if (mode === 'prepare') { } else if (mode === 'assert') { const root = repo; const config = JSON.parse(readFileSync(path.join(repo, 'graph.json'), 'utf8')).products.alpha; + assert.equal(historyCommit(root, 'beta-v7.0.0'), historyCommit(root, 'refs/tags/beta-v7.0.0')); + assert.equal(historyFile(root, 'beta-v7.0.0', 'blue/VERSION'), '7.0.0'); assert.equal(historyFile(root, 'alpha-v1.0.0', 'red/VERSION'), '1.0.0'); assert.deepEqual(historyManifest(root, 'alpha-v1.0.0'), { red: '1.0.0', blue: '8.0.0' }); assert.equal( diff --git a/tools/release/release-history.test.sh b/tools/release/release-history.test.sh index be1ee79a9..6f4b511b9 100644 --- a/tools/release/release-history.test.sh +++ b/tools/release/release-history.test.sh @@ -14,6 +14,13 @@ printf 1.0.0 > blue/VERSION printf 0.5.0 > blue/PIN printf 8.0.0 > red/VERSION printf 9.0.0 > red/PIN +printf 7.0.0 > red/VERSION +printf '%s' '{"blue":"1.0.0","red":"7.0.0"}' > .release-please-manifest.json +git add . +git commit -qm fixture +git tag beta-v7.0.0 +printf 8.0.0 > red/VERSION +printf '%s' '{"blue":"1.0.0","red":"8.0.0"}' > .release-please-manifest.json git add . git commit -qm fixture git tag alpha-v1.0.0 @@ -24,7 +31,7 @@ mv temp red printf '%s' '{"packages":{"red":{"component":"alpha"},"blue":{"component":"beta"}}}' > release-please-config.json printf '%s' '{"red":"1.1.0","blue":"8.0.0"}' > .release-please-manifest.json printf 1.1.0 > red/VERSION -printf 0.6.0 > red/PIN +printf 7.0.0 > red/PIN printf 0.6.0 > red/NEW_PIN git add . git commit -qm fixture diff --git a/tools/release/release-verification-shell.test.sh b/tools/release/release-verification-shell.test.sh index 809bdd486..a704b7932 100644 --- a/tools/release/release-verification-shell.test.sh +++ b/tools/release/release-verification-shell.test.sh @@ -3,10 +3,14 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' EXIT -export TEST_BUN_VERSION="$(bun --version)" CALL_LOG="$scratch/calls" +export TEST_BUN_VERSION="$(bun --version)" CALL_LOG="$scratch/calls" REAL_BUN="$(command -v bun)" cat > "$scratch/bun" <<'SH' #!/usr/bin/env bash if [[ "${1:-}" == --version ]]; then echo "$TEST_BUN_VERSION"; exit 0; fi +# Materialize real historical product inputs; only publication gates are mocked. +case "${1:-}:${2:-}" in + */release-graph.mts:--history-inputs|*/release-history.mts:files) exec "$REAL_BUN" "$@" ;; +esac printf '%s\0' "$@" >> "$CALL_LOG" printf '\n' >> "$CALL_LOG" [[ "$1" != "${FAIL_GATE:-}" ]] || exit 9 diff --git a/tools/release/release-version-tags.test.sh b/tools/release/release-version-tags.test.sh index c6f4f886c..11d7940b6 100644 --- a/tools/release/release-version-tags.test.sh +++ b/tools/release/release-version-tags.test.sh @@ -30,10 +30,21 @@ git mv extensions src/extensions git commit -qm 'move source under src' git tag liboliphaunt-native-v0.3.0 cp "$source_root/tools/release/with-release-tags.sh" tools/release/ +cp "$source_root/tools/release/with-product-history.sh" tools/release/ cp "$source_root/tools/release/check-release-versions.sh" check.sh cat > tools/dev/bun.sh <<'SH' #!/usr/bin/env bash set -eu +# This tag-only repository has no products or historical product files. +case "$1:${2:-}" in + */release-graph.mts:--history-inputs) + : > "$OLIPHAUNT_PRODUCT_HISTORY/prefixes" + : > "$OLIPHAUNT_PRODUCT_HISTORY/current-tags" + exit 0 ;; + */release-history.mts:files) + for directory in "$OLIPHAUNT_PRODUCT_HISTORY"/trees/*; do : > "$directory/selected"; done + exit 0 ;; +esac printf '%s' "$RELEASE_HEAD_COMMIT" > head cp "$RELEASE_TAG_REFS" refs cp "$RELEASE_TAG_COMMITS" commits From f4e1c507d11cb8f77b06bb0e703b1b5b44d972f9 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Wed, 7 Oct 2026 02:55:47 +0000 Subject: [PATCH 2/2] fix(release): keep consumer cleanup in the coordinator --- tools/release/public-consumer-smoke.sh | 5 ++++- tools/release/public-consumer-smoke.test.sh | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tools/release/public-consumer-smoke.sh b/tools/release/public-consumer-smoke.sh index 822954d42..b0167e48a 100644 --- a/tools/release/public-consumer-smoke.sh +++ b/tools/release/public-consumer-smoke.sh @@ -99,7 +99,10 @@ scratch="$(mktemp -d)" pids=() # shellcheck disable=SC2317 cleanup() { - status=$? + local status=$? + # A child signalled before exec can run this inherited EXIT trap. Only the + # coordinator owns the other process groups and shared scratch directory. + [ "$BASH_SUBSHELL" -eq 0 ] || return "$status" trap - EXIT for pid in ${pids[@]+"${pids[@]}"}; do kill -TERM -- "-$pid" 2>/dev/null || true; done if [ "${#pids[@]}" -gt 0 ]; then sleep 2; fi diff --git a/tools/release/public-consumer-smoke.test.sh b/tools/release/public-consumer-smoke.test.sh index d9c8f9342..4b9741ba8 100644 --- a/tools/release/public-consumer-smoke.test.sh +++ b/tools/release/public-consumer-smoke.test.sh @@ -79,6 +79,7 @@ case "$2" in [[ "${4:-}" != --help ]] || exit 0 printf '{"deadlineMilliseconds":%s,"plan":{"surfaces":[{"ecosystem":"cargo"},{"ecosystem":"npm"},{"ecosystem":"maven"}]}}\n' "$(( ($(date +%s) + 60) * 1000 ))" > "$3/context.json" ;; --report) + [[ -f "$3/context.json" ]] for surface in cargo npm maven github; do [[ -f "$PUBLIC_PROBE_COORDINATOR/$surface" ]]; done touch "$PUBLIC_PROBE_COORDINATOR/report" ;; *) exit 99 ;; @@ -105,3 +106,22 @@ status=0 FAIL_PUBLIC_PROBE=npm PATH="$coordinator/bin:$PATH" "$PUBLIC_PROBE_BASH" "$coordinator/tools/release/public-consumer-smoke.sh" || status=$? [[ "$status" == 7 && ! -f "$coordinator/report" ]] echo 'Public consumer coordinator: help, all probes drained, report after success, and failed probe blocks report passed' + +# Bash can receive a signal before a background launch resets its inherited +# EXIT trap. Keep that trap active in a wrapper to force the ownership case +# without relying on runner load or signal timing. +gtimeout() { + local status=0 + "$PUBLIC_PROBE_TIMEOUT" "$@" || status=$? + trap cleanup EXIT + exit "$status" +} +export -f gtimeout +PATH="$coordinator/bin:$PATH" "$PUBLIC_PROBE_BASH" "$coordinator/tools/release/public-consumer-smoke.sh" +[[ -f "$coordinator/report" ]] +status=0 +rm "$coordinator/report" +FAIL_PUBLIC_PROBE=npm PATH="$coordinator/bin:$PATH" "$PUBLIC_PROBE_BASH" "$coordinator/tools/release/public-consumer-smoke.sh" || status=$? +[[ "$status" == 7 && ! -f "$coordinator/report" ]] +unset -f gtimeout +echo 'Public consumer coordinator: inherited child EXIT traps preserve shared state and the original failure passed'