From b30229d88cbf44016f977d91c065de8feaedc706 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 5 Oct 2026 01:15:35 +0000 Subject: [PATCH 1/6] fix(wasix): align workspace qualification with independent release pins --- src/wasix/node-addon/README.md | 5 ++ src/wasix/node-addon/moon.yml | 3 +- src/wasix/node-addon/release.toml | 11 +++ src/wasix/node-addon/tools/build-native.sh | 2 + .../node-addon/tools/check-release-assets.mts | 27 ++++-- .../node-addon/tools/native-build-data.mts | 22 +++-- .../node-addon/tools/package-platform.mts | 13 +++ .../node-addon/tools/package-platform.test.sh | 5 ++ .../tools/workspace-runtime-contract.mts | 35 ++++++++ .../tools/integration/packed-node-fixture.mts | 43 ++++++++- tools/release/check-release-metadata.mts | 20 +++++ .../release/compatibility-version-policy.mts | 12 +++ .../release/independent-version-pins.test.mts | 90 +++++++++++++++++++ tools/release/moon.yml | 4 + .../prepare-release-candidate.test.mts | 20 ++++- tools/release/publication-lock.mts | 8 ++ tools/release/publication-lock.test.mts | 12 +++ tools/release/release-graph.mts | 18 +++- tools/release/sync-release-pr.mts | 16 ++++ 19 files changed, 342 insertions(+), 24 deletions(-) create mode 100644 src/wasix/node-addon/tools/workspace-runtime-contract.mts diff --git a/src/wasix/node-addon/README.md b/src/wasix/node-addon/README.md index b923ac0dc..a98232c50 100644 --- a/src/wasix/node-addon/README.md +++ b/src/wasix/node-addon/README.md @@ -97,6 +97,11 @@ Rust target triple. The addon's `runtimeVersion()` identity comes directly from the selected `liboliphaunt-wasix-portable` crate. Workspace builds therefore report the local runtime while released carriers retain exact product compatibility pins. +Workspace carrier manifests report that compiled runtime. If the checkout's +runtime or Rust binding differs from the committed release pins, staging marks +the carrier `qualificationOnly`; publication refuses to freeze it. The ordinary +release validator continues to require the declared pins, while the CI aggregate +explicitly validates the workspace contract with `--workspace`. Product metadata tracks the runtime and `oliphaunt-wasix` Rust binding as separate compatibility versions; they are not assumed to advance together. diff --git a/src/wasix/node-addon/moon.yml b/src/wasix/node-addon/moon.yml index 80e622df5..6e6f7f1a9 100644 --- a/src/wasix/node-addon/moon.yml +++ b/src/wasix/node-addon/moon.yml @@ -130,6 +130,7 @@ tasks: - "/src/wasix/node-addon/src/**/*" - "/src/wasix/node-addon/tools/build-native.sh" - "/src/wasix/node-addon/tools/native-build-data.mts" + - "/src/wasix/node-addon/tools/workspace-runtime-contract.mts" - "/src/wasix/node-addon/tools/check-build-inputs.mts" - "/src/wasix/node-addon/tools/detect-linux-libc.mts" - "/src/wasix/node-addon/tools/package-platform.mts" @@ -182,7 +183,7 @@ tasks: finalize-release-assets: tags: ["release", "artifact-package", "in-place-finalizer", "ci-wasix-napi-release-assets"] - command: "tools/dev/bun.sh src/wasix/node-addon/tools/check-release-assets.mts --aggregate" + command: "tools/dev/bun.sh src/wasix/node-addon/tools/check-release-assets.mts --aggregate --workspace" deps: - "oliphaunt-wasix-napi:build-release-assets" inputs: diff --git a/src/wasix/node-addon/release.toml b/src/wasix/node-addon/release.toml index c3009e26e..0e00cdae0 100644 --- a/src/wasix/node-addon/release.toml +++ b/src/wasix/node-addon/release.toml @@ -9,6 +9,17 @@ registry_packages = [ "npm:@oliphaunt/wasix-napi-win32-x64-msvc", ] release_artifacts = ["node-api-prebuilds", "npm-optional-platform-packages"] +shared_source_paths = [ + "src/wasix/sdks/rust/src", + "src/wasix/sdks/rust/build.rs", + "src/wasix/sdks/rust/Cargo.toml", + "src/wasix/pgwire-server/src", + "src/wasix/pgwire-server/Cargo.toml", + "src/query/rust/src", + "src/query/rust/Cargo.toml", + "src/wasix/runtime/assets", + "src/wasix/runtime/crates", +] [compatibility_versions.oliphaunt-wasix-napi-runtime] source_product = "liboliphaunt-wasix" diff --git a/src/wasix/node-addon/tools/build-native.sh b/src/wasix/node-addon/tools/build-native.sh index eaad10c95..64fe032c4 100755 --- a/src/wasix/node-addon/tools/build-native.sh +++ b/src/wasix/node-addon/tools/build-native.sh @@ -82,6 +82,8 @@ fi manifest="src/wasix/node-addon/Cargo.toml" package_manifest="src/wasix/node-addon/package.json" +# Source qualification compiles the local runtime. Package staging records this +# identity and marks mismatched release pins as ineligible for publication. metadata_contract="$( bun "$workspace_root/src/wasix/node-addon/tools/native-build-data.mts" metadata "$package_manifest" )" diff --git a/src/wasix/node-addon/tools/check-release-assets.mts b/src/wasix/node-addon/tools/check-release-assets.mts index 5c346da44..c479b8699 100644 --- a/src/wasix/node-addon/tools/check-release-assets.mts +++ b/src/wasix/node-addon/tools/check-release-assets.mts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { workspaceCarrierManifest } from './workspace-runtime-contract.mts'; import { finalizeHelperAssets } from '../../../../tools/packaging/finalize-helper-assets.mts'; import { inspectPlatformBinaryEntries } from '../../../../tools/packaging/platform-binary-contract.mts'; import { readPortableArchiveEntries } from '../../../../tools/packaging/portable-archive.mts'; @@ -206,7 +207,13 @@ function assertBuildInputs(buildInputs, target, label) { } } -export function assertWasixNapiCarrierManifest(manifest, target, version, label = 'carrier') { +export function assertWasixNapiCarrierManifest( + manifest, + target, + version, + label = 'carrier', + contract = PRODUCT_MANIFEST.oliphaunt, +) { if (manifest.name !== target.npmPackage || manifest.version !== version) { throw new Error(`${label} must identify ${target.npmPackage}@${version}`); } @@ -218,7 +225,8 @@ export function assertWasixNapiCarrierManifest(manifest, target, version, label if ( manifest.oliphaunt?.target !== target.target || manifest.oliphaunt?.runtimeProduct !== PRODUCT_MANIFEST.oliphaunt.runtimeProduct || - manifest.oliphaunt?.runtimeVersion !== PRODUCT_MANIFEST.oliphaunt.runtimeVersion || + manifest.oliphaunt?.runtimeVersion !== contract.runtimeVersion || + manifest.oliphaunt?.qualificationOnly !== contract.qualificationOnly || manifest.oliphaunt?.addonAbiVersion !== PRODUCT_MANIFEST.oliphaunt.addonAbiVersion || manifest.oliphaunt?.nodeApiVersion !== PRODUCT_MANIFEST.oliphaunt.nodeApiVersion || JSON.stringify(manifest.oliphaunt?.profiles) !== JSON.stringify(['standard', 'icu']) @@ -319,7 +327,7 @@ export function assertWasixNapiPlatformEntries( } } -function assertPayload(entries, { prefix = '', label, target, version, npm = false }) { +function assertPayload(entries, { prefix = '', label, target, version, npm = false, contract }) { assertReleaseNoticesInEntries(entries, { profile: PROFILE, prefix, label }); const member = (name) => (prefix ? `${prefix}/${name}` : name); const provenance = archiveJson(entries, member('artifact-provenance.json'), label); @@ -368,10 +376,10 @@ function assertPayload(entries, { prefix = '', label, target, version, npm = fal if (!npm) return; assertSingleWasixNapiAddonMember(entries, binaryMember, label); const manifest = archiveJson(entries, member('package.json'), label); - assertWasixNapiCarrierManifest(manifest, target, version, label); + assertWasixNapiCarrierManifest(manifest, target, version, label, contract); } -export function assertWasixNapiNpmArchive(file, targets, version) { +export function assertWasixNapiNpmArchive(file, targets, version, contract) { const label = path.basename(file); let entries; try { @@ -386,7 +394,7 @@ export function assertWasixNapiNpmArchive(file, targets, version) { `${label} package name is not a published WASIX Node-API carrier: ${JSON.stringify(manifest.name)}`, ); } - assertPayload(entries, { prefix: 'package', label, target, version, npm: true }); + assertPayload(entries, { prefix: 'package', label, target, version, npm: true, contract }); assertWasixNapiPlatformEntries(entries, { target: target.target, label, @@ -410,6 +418,11 @@ async function validateArchive(file, target, version) { } export async function checkWasixNapiReleaseAssets(argv) { + const workspace = argv.includes('--workspace'); + argv = argv.filter((arg) => arg !== '--workspace'); + const contract = workspace + ? workspaceCarrierManifest(PRODUCT_MANIFEST, PRODUCT_MANIFEST.oliphaunt).oliphaunt + : undefined; if (argv.includes('--aggregate')) argv = await finalizeHelperAssets(PRODUCT, KIND, argv, { assetDir: @@ -467,7 +480,7 @@ export async function checkWasixNapiReleaseAssets(argv) { } for (const npmPackage of args.npmPackages) { try { - assertWasixNapiNpmArchive(npmPackage, targets, version); + assertWasixNapiNpmArchive(npmPackage, targets, version, contract); } catch (error) { fail(PREFIX, error.message); } diff --git a/src/wasix/node-addon/tools/native-build-data.mts b/src/wasix/node-addon/tools/native-build-data.mts index 87cbfb4a2..f9ee896fe 100644 --- a/src/wasix/node-addon/tools/native-build-data.mts +++ b/src/wasix/node-addon/tools/native-build-data.mts @@ -1,4 +1,5 @@ import { createRequire } from 'node:module'; +import { workspaceRuntimeVersion } from './workspace-runtime-contract.mts'; const require = createRequire(import.meta.url); const [command, ...args] = process.argv.slice(2); @@ -6,7 +7,7 @@ switch (command) { case 'metadata': { const manifest = JSON.parse(require('node:fs').readFileSync(args[0], 'utf8')); const values = [ - manifest.oliphaunt?.runtimeVersion, + workspaceRuntimeVersion(), manifest.oliphaunt?.addonAbiVersion, manifest.oliphaunt?.nodeApiVersion, ]; @@ -53,13 +54,18 @@ switch (command) { throw new Error(`${addonPath} is missing function export ${name}`); } } - if ( - addon.addonAbiVersion() !== expectedAbi || - addon.nodeApiVersion() !== expectedNodeApi || - addon.runtimeVersion() !== expectedRuntime || - JSON.stringify(addon.supportedProfiles()) !== JSON.stringify(['standard', 'icu']) - ) { - throw new Error(`${addonPath} reports an incompatible ABI/runtime/profile contract`); + for (const [name, expected] of [ + ['addonAbiVersion', expectedAbi], + ['nodeApiVersion', expectedNodeApi], + ['runtimeVersion', expectedRuntime], + ['supportedProfiles', ['standard', 'icu']], + ]) { + const actual = addon[name](); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `${addonPath} ${name} reports ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}`, + ); + } } function expectedIdentity(record, kind) { diff --git a/src/wasix/node-addon/tools/package-platform.mts b/src/wasix/node-addon/tools/package-platform.mts index 67484d4d4..19165f3e9 100755 --- a/src/wasix/node-addon/tools/package-platform.mts +++ b/src/wasix/node-addon/tools/package-platform.mts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { workspaceCarrierManifest } from './workspace-runtime-contract.mts'; import { archiveDirectory } from '../../../../tools/packaging/archive-directory.mts'; import { readPortableArchiveEntries } from '../../../../tools/packaging/portable-archive.mts'; @@ -113,6 +114,18 @@ async function main() { mkdirSync(path.join(packageWork, 'prebuilds'), { recursive: true }); mkdirSync(packageOutput, { recursive: true }); cpSync(sourcePackage, packageWork, { recursive: true }); + writeFileSync( + path.join(packageWork, 'package.json'), + `${JSON.stringify( + workspaceCarrierManifest( + readJson(path.join(sourcePackage, 'package.json')), + rootManifest.oliphaunt, + WORKSPACE_ROOT, + ), + null, + 2, + )}\n`, + ); const packagePrebuilds = path.join(packageWork, 'prebuilds'); mkdirSync(packagePrebuilds, { recursive: true }); cpSync(path.join(prebuildDirectory, BINARY), path.join(packagePrebuilds, BINARY)); diff --git a/src/wasix/node-addon/tools/package-platform.test.sh b/src/wasix/node-addon/tools/package-platform.test.sh index a3e509a88..c7ed10e6c 100755 --- a/src/wasix/node-addon/tools/package-platform.test.sh +++ b/src/wasix/node-addon/tools/package-platform.test.sh @@ -7,6 +7,11 @@ fixture="$(mktemp -d)" trap 'rm -rf "$fixture"' EXIT product="$fixture/src/wasix/node-addon" mkdir -p "$product/tools" "$fixture/tools" "$fixture/prebuild" +mkdir -p "$fixture/src/wasix/runtime" +mkdir -p "$fixture/src/wasix/sdks/rust" +cp "$root/src/wasix/runtime/VERSION" "$fixture/src/wasix/runtime/" +cp "$root/src/wasix/sdks/rust/Cargo.toml" "$fixture/src/wasix/sdks/rust/" +cp "$root/src/wasix/node-addon/tools/workspace-runtime-contract.mts" "$product/tools/" cp "$root/src/wasix/node-addon/tools/package-platform."{sh,mts} "$product/tools/" cp "$root/src/wasix/node-addon/tools/smoke-packaged-addon."{sh,mts} "$product/tools/" cp "$root/src/wasix/node-addon/package.json" "$product/" diff --git a/src/wasix/node-addon/tools/workspace-runtime-contract.mts b/src/wasix/node-addon/tools/workspace-runtime-contract.mts new file mode 100644 index 000000000..f3af2e276 --- /dev/null +++ b/src/wasix/node-addon/tools/workspace-runtime-contract.mts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readCargoPackageNameVersion } from '../../../../tools/packaging/cargo-source-package.mts'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + +export function workspaceRuntimeVersion(root = ROOT) { + const version = readFileSync(path.join(root, 'src/wasix/runtime/VERSION'), 'utf8').trim(); + if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(version)) { + throw new Error('workspace WASIX runtime must have a stable version'); + } + return version; +} + +// A source checkout compiles its local runtime. Preserve committed release +// pins, and identify a carrier built against different workspace dependencies +// so publication cannot mistake it for the immutable released package. +export function workspaceCarrierManifest(manifest, declaredContract, root = ROOT) { + const runtimeVersion = workspaceRuntimeVersion(root); + const rustBindingVersion = readCargoPackageNameVersion( + path.join(root, 'src/wasix/sdks/rust/Cargo.toml'), + ).version; + return { + ...manifest, + oliphaunt: { + ...manifest.oliphaunt, + runtimeVersion, + ...(runtimeVersion !== declaredContract.runtimeVersion || + rustBindingVersion !== declaredContract.rustBindingVersion + ? { qualificationOnly: true } + : {}), + }, + }; +} diff --git a/src/wasix/sdks/ts/tools/integration/packed-node-fixture.mts b/src/wasix/sdks/ts/tools/integration/packed-node-fixture.mts index f1a14f97a..d06363a3f 100644 --- a/src/wasix/sdks/ts/tools/integration/packed-node-fixture.mts +++ b/src/wasix/sdks/ts/tools/integration/packed-node-fixture.mts @@ -11,6 +11,7 @@ import { import { createDeterministicTar } from '../../../../../../tools/packaging/cargo-source-package.mts'; import { canonicalGzipSync, + extractPortableArchiveTree, readPortableArchiveEntries, } from '../../../../../../tools/packaging/portable-archive.mts'; import { packWasixToolsNpmCarrier } from '../../../../postgres-tools/tools/wasix-tools-npm-carrier.mts'; @@ -54,7 +55,12 @@ export async function stagePackedWasixConsumer({ const tarballs = resolve(scratch, 'tarballs'); await mkdir(tarballs, { recursive: true }); - const binding = await packedBinding(); + const binding = await packedBinding({ + scratch, + tarballs, + runtimeVersion, + nativeVersion: releaseVersions['src/wasix/node-addon'], + }); const queryFile = resolve(scratch, 'query.tgz'); const queryEntries = readPortableArchiveEntries(queryFile); const queryManifest = queryEntries.get('package/package.json'); @@ -196,15 +202,46 @@ export default Object.freeze({ return pack(staging, tarballs); } -async function packedBinding() { +export function workspaceBindingManifest(manifest, { runtimeVersion, nativeVersion }) { + requireReleaseVersion(runtimeVersion, 'liboliphaunt-wasix'); + requireReleaseVersion(nativeVersion, 'oliphaunt-wasix-napi'); + return { + ...manifest, + oliphaunt: { + ...manifest.oliphaunt, + runtimeVersion, + wasixNapiVersion: nativeVersion, + qualificationOnly: true, + }, + dependencies: { ...manifest.dependencies, '@oliphaunt/liboliphaunt-wasix': runtimeVersion }, + optionalDependencies: Object.fromEntries( + Object.keys(manifest.optionalDependencies).map((name) => [name, nativeVersion]), + ), + }; +} + +async function packedBinding({ scratch, tarballs, runtimeVersion, nativeVersion }) { const { version } = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')); const file = resolve( repositoryRoot, `target/oliphaunt-wasix-ts/package/packages/oliphaunt-wasix-ts-${version}.tgz`, ); const manifest = assertWasixTypescriptNpmArchive(file); - const nativeVersion = manifest.oliphaunt?.wasixNapiVersion; requireReleaseVersion(nativeVersion, 'oliphaunt-wasix-napi'); + if ( + manifest.oliphaunt.runtimeVersion !== runtimeVersion || + manifest.oliphaunt.wasixNapiVersion !== nativeVersion + ) { + // Source qualification runs against this checkout's producers. Keep the + // release tarball intact and use a private, unpublishable copy for the test. + const staging = resolve(scratch, 'workspace-binding'); + extractPortableArchiveTree(file, staging, 'package'); + await writeJson( + resolve(staging, 'package.json'), + workspaceBindingManifest(manifest, { runtimeVersion, nativeVersion }), + ); + return { ...(await pack(staging, tarballs)), nativeVersion }; + } const bytes = await readFile(file); return { file, diff --git a/tools/release/check-release-metadata.mts b/tools/release/check-release-metadata.mts index 9c12897b8..9703b6324 100755 --- a/tools/release/check-release-metadata.mts +++ b/tools/release/check-release-metadata.mts @@ -10,6 +10,7 @@ import { compatibilityVersionSource, requireCompatibilityVersionBinding, requireCompatibilityVersionBounds, + requireMatchingWasixRuntime, } from './compatibility-version-policy.mts'; import { declaredCarrierMap, @@ -365,6 +366,25 @@ function validateCompatibility(graph, { publication = false } = {}) { { prefix: TOOL }, ); } + if (publication) { + const pin = (product, sourceProduct, options = {}) => + compatibilityVersionValue( + entries.find((entry) => entry.product === product && entry.sourceProduct === sourceProduct), + { prefix: TOOL, ...options }, + ); + const napiProduct = graph.products['oliphaunt-wasix-napi']; + const napiVersion = pin('oliphaunt-wasix-ts', 'oliphaunt-wasix-napi'); + requireMatchingWasixRuntime( + { + runtimeVersion: pin('oliphaunt-wasix-ts', 'liboliphaunt-wasix'), + napiVersion, + napiRuntimeVersion: pin('oliphaunt-wasix-napi', 'liboliphaunt-wasix', { + ref: napiVersion === napiProduct.version ? null : napiProduct.tag_prefix + napiVersion, + }), + }, + { prefix: TOOL }, + ); + } return entries.length; } diff --git a/tools/release/compatibility-version-policy.mts b/tools/release/compatibility-version-policy.mts index 29caeddc2..08c377c94 100644 --- a/tools/release/compatibility-version-policy.mts +++ b/tools/release/compatibility-version-policy.mts @@ -28,6 +28,18 @@ function compareVersions(left, right) { return 0; } +export function requireMatchingWasixRuntime( + { runtimeVersion, napiVersion, napiRuntimeVersion }, + { prefix = 'compatibility-version-policy' } = {}, +) { + if (runtimeVersion !== napiRuntimeVersion) { + throw policyError( + prefix, + `oliphaunt-wasix-ts runtime ${runtimeVersion} differs from oliphaunt-wasix-napi ${napiVersion} runtime ${napiRuntimeVersion}; select a new addon release before advancing the SDK runtime`, + ); + } +} + /** * Choose the immutable source of a compatibility field. A sink whose manifest * is pending from the latest verified release commit follows the current diff --git a/tools/release/independent-version-pins.test.mts b/tools/release/independent-version-pins.test.mts index f6c8caca1..83edd29a3 100644 --- a/tools/release/independent-version-pins.test.mts +++ b/tools/release/independent-version-pins.test.mts @@ -20,6 +20,13 @@ import { renderReleaseCargoToml } from '../../src/native/sdks/rust/tools/prepare import { renderOliphauntWasixReleaseCargoToml } from '../../src/wasix/sdks/rust/tools/prepare-rust-release-source.mts'; import { prepareWasixTypescriptPackage } from '../../src/wasix/sdks/ts/tools/package.mts'; import { assertWasixTypescriptManifest } from '../../src/wasix/sdks/ts/tools/wasix-typescript-package.mts'; +import { + workspaceCarrierManifest, + workspaceRuntimeVersion, +} from '../../src/wasix/node-addon/tools/workspace-runtime-contract.mts'; +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'; 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'); @@ -121,6 +128,89 @@ test('WASIX TypeScript npm staging uses its portable runtime and Node-API pins', } }); +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'); + const staged = workspaceCarrierManifest(original, product.oliphaunt); + assert.equal(staged.oliphaunt.runtimeVersion, workspaceRuntimeVersion()); + assert.notEqual(staged.oliphaunt.runtimeVersion, original.oliphaunt.runtimeVersion); + assert.equal(staged.oliphaunt.qualificationOnly, true); + const currentRust = Bun.TOML.parse(read('src/wasix/sdks/rust/Cargo.toml')).package.version; + const currentContract = { + runtimeVersion: workspaceRuntimeVersion(), + rustBindingVersion: currentRust, + }; + const aligned = workspaceCarrierManifest(original, currentContract); + assert.equal(Object.hasOwn(aligned.oliphaunt, 'qualificationOnly'), false); + const rustOnly = workspaceCarrierManifest(original, { + ...currentContract, + rustBindingVersion: '0.0.0', + }); + assert.equal(rustOnly.oliphaunt.qualificationOnly, true); + assert.deepEqual(json('src/wasix/node-addon/packages/linux-x64-gnu/package.json'), original); + const target = { + target: 'linux-x64-gnu', + npmPackage: original.name, + npmOs: 'linux', + npmCpu: 'x64', + npmLibc: 'glibc', + }; + assert.doesNotThrow(() => + assertWasixNapiCarrierManifest(staged, target, original.version, 'workspace', staged.oliphaunt), + ); + assert.throws( + () => assertWasixNapiCarrierManifest(staged, target, original.version), + /runtime\/ABI\/profile metadata/u, + ); + const result = spawnSync( + process.execPath, + [ + 'src/wasix/node-addon/tools/native-build-data.mts', + 'metadata', + 'src/wasix/node-addon/package.json', + ], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.split('\t')[0], staged.oliphaunt.runtimeVersion); +}); + +test('WASIX SDK release requires the same portable and addon runtime', () => { + assert.doesNotThrow(() => + requireMatchingWasixRuntime({ + runtimeVersion: '1.2.3', + napiVersion: '4.5.6', + napiRuntimeVersion: '1.2.3', + }), + ); + assert.throws( + () => + requireMatchingWasixRuntime({ + runtimeVersion: '1.2.4', + napiVersion: '4.5.6', + napiRuntimeVersion: '1.2.3', + }), + /select a new addon release/u, + ); +}); + +test('WASIX workspace consumer uses current producers while its release package retains its pins', () => { + const original = prepareWasixTypescriptPackage( + stageManifest('wasix-workspace-consumer', 'src/wasix/sdks/ts/package.json'), + ); + const snapshot = structuredClone(original); + const runtimeVersion = workspaceRuntimeVersion(); + const nativeVersion = manifest[products['oliphaunt-wasix-napi'].path]; + const staged = workspaceBindingManifest(original, { runtimeVersion, nativeVersion }); + assertWasixTypescriptManifest(staged); + assert.equal(staged.oliphaunt.runtimeVersion, runtimeVersion); + assert.equal(staged.dependencies['@oliphaunt/liboliphaunt-wasix'], runtimeVersion); + assert.equal(staged.oliphaunt.qualificationOnly, true); + for (const value of Object.values(staged.optionalDependencies)) + assert.equal(value, nativeVersion); + assert.deepEqual(original, snapshot); +}); + test('native Rust source generation uses its native runtime and broker pins', () => { const source = read('src/native/sdks/rust/Cargo.toml'); const original = Bun.TOML.parse(source); diff --git a/tools/release/moon.yml b/tools/release/moon.yml index ce0ae9adb..1a96fa9ec 100644 --- a/tools/release/moon.yml +++ b/tools/release/moon.yml @@ -168,6 +168,10 @@ tasks: - /src/wasix/sdks/rust/tools/prepare-rust-release-source.mts - /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/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 - /tools/ci/with-projects.sh - /tools/dev/bun.sh options: diff --git a/tools/release/prepare-release-candidate.test.mts b/tools/release/prepare-release-candidate.test.mts index 875f92a6d..d1d9ac7c1 100644 --- a/tools/release/prepare-release-candidate.test.mts +++ b/tools/release/prepare-release-candidate.test.mts @@ -1,7 +1,7 @@ import { test, expect } from 'bun:test'; import { Manifest } from 'release-please'; import { Version } from 'release-please/build/src/version.js'; -import { loadGraph } from './release-graph.mts'; +import { buildPlan, loadGraph } from './release-graph.mts'; import { includeOwnedSourceCommits, useSourceDate, @@ -19,6 +19,24 @@ const first = 'a'.repeat(40); const second = 'b'.repeat(40); const head = 'c'.repeat(40); +test('addon releases include embedded Rust and runtime sources without releasing unrelated SDKs', () => { + for (const [file, owner] of [ + ['src/wasix/sdks/rust/src/oliphaunt/base.rs', 'oliphaunt-wasix-rust'], + ['src/wasix/pgwire-server/src/lib.rs', 'oliphaunt-pgwire-server'], + ['src/query/rust/src/lib.rs', 'oliphaunt-query'], + ['src/wasix/runtime/crates/assets/src/lib.rs', 'liboliphaunt-wasix'], + ]) { + const plan = buildPlan(graph, [file]); + expect(plan.releaseProducts.sort()).toEqual([owner, 'oliphaunt-wasix-napi'].sort()); + } + for (const file of [ + 'src/wasix/sdks/rust/tests/runtime_smoke.rs', + 'src/wasix/sdks/rust/README.md', + ]) { + expect(buildPlan(graph, [file]).releaseProducts).not.toContain('oliphaunt-wasix-napi'); + } +}); + async function generate(commits, baselines = { [native]: first, [wasix]: first }) { useSourceDate('2026-09-11'); const config = Object.fromEntries( diff --git a/tools/release/publication-lock.mts b/tools/release/publication-lock.mts index 60679040a..4959e0f97 100644 --- a/tools/release/publication-lock.mts +++ b/tools/release/publication-lock.mts @@ -258,6 +258,9 @@ function npmArtifact(file) { ecosystem: 'npm', name: manifest.name, version: manifest.version, + ...(Object.hasOwn(manifest.oliphaunt ?? {}, 'qualificationOnly') + ? { qualificationOnly: true } + : {}), dependencies: dependencyRows('npm', [ ['runtime', manifest.dependencies], ['optional', manifest.optionalDependencies], @@ -2041,6 +2044,11 @@ export function buildPublicationCandidate({ if (!selectedProducts.has(resolved.product)) { continue; } + if (artifact.qualificationOnly) { + throw error( + `${artifact.name}@${artifact.version} is a workspace qualification carrier; prepare a new release with matching dependency pins`, + ); + } if (artifact.version !== resolved.version) { throw error( `${artifact.ecosystem}:${artifact.name} artifact version ${artifact.version} does not match ${resolved.product} version ${resolved.version}`, diff --git a/tools/release/publication-lock.test.mts b/tools/release/publication-lock.test.mts index 389fb69d4..42646705b 100644 --- a/tools/release/publication-lock.test.mts +++ b/tools/release/publication-lock.test.mts @@ -969,6 +969,7 @@ describe('publication artifact discovery and freezing', () => { expect(unselectedNpm).toBeDefined(); npmFixture(path.join(root, 'unselected-a'), unselectedNpm.name, unselectedNpm.version, { description: 'first unselected carrier bytes', + oliphaunt: { qualificationOnly: true }, }); npmFixture(path.join(root, 'unselected-b'), unselectedNpm.name, unselectedNpm.version, { description: 'second unselected carrier bytes', @@ -1006,6 +1007,17 @@ describe('publication artifact discovery and freezing', () => { ).toThrow(/artifact identity cargo:undeclared-publication-carrier is not declared/u); }); + test('refuses to freeze a selected workspace qualification carrier', () => { + const root = temporaryDirectory(); + const catalog = loadPublicationCatalog('publication-lock.test', { products: ['oliphaunt-js'] }); + npmFixture(root, '@oliphaunt/ts', catalog.products[0].version, { + oliphaunt: { qualificationOnly: true }, + }); + expect(() => + buildPublicationCandidate({ products: ['oliphaunt-js'], artifactRoots: [root] }), + ).toThrow('workspace qualification carrier'); + }); + test.each([ 'oliphaunt-broker', 'postgres-tools-native', diff --git a/tools/release/release-graph.mts b/tools/release/release-graph.mts index 83252a5d1..aa4b28d5b 100644 --- a/tools/release/release-graph.mts +++ b/tools/release/release-graph.mts @@ -1261,9 +1261,9 @@ export function buildPlan(graph, files, prefix = 'release-graph') { directProjects.add(releaseProductProjectId(product, products, projects, prefix)); } } - // The explicit carrier mapping is authoritative. Traversing the shared - // source project's other consumers would fabricate downstream releases. - continue; + // Contrib's carrier mapping is authoritative. Declared shared sources + // also retain the product that owns the source itself. + if (sharedImpacts.every((impact) => impact.source_paths === undefined)) continue; } // Documentation alone does not request a product release. Declared // changelogs and explicit shared release inputs remain release-affecting. @@ -1422,7 +1422,17 @@ if (import.meta.main) { ['wanted-files', files], [ 'current-tags', - Object.values(products).map((config) => 'refs/tags/' + config.tag_prefix + config.version), + new Set([ + ...Object.values(products).map( + (config) => 'refs/tags/' + config.tag_prefix + config.version, + ), + ...compatibilityVersionEntries(products, { root, requireSourceProduct: true }).map( + (entry) => + 'refs/tags/' + + products[entry.sourceProduct].tag_prefix + + compatibilityVersionValue(entry, { root }), + ), + ]), ], ]) writeFileSync(path.join(directory, name), [...values].map((value) => value + '\0').join('')); diff --git a/tools/release/sync-release-pr.mts b/tools/release/sync-release-pr.mts index c1eaad511..9c3f72a45 100644 --- a/tools/release/sync-release-pr.mts +++ b/tools/release/sync-release-pr.mts @@ -14,6 +14,7 @@ import { ROOT, } from './release-artifact-targets.mts'; import { compatibilityVersionEntries, loadGraph, loadProducts } from './release-graph.mts'; +import { requireMatchingWasixRuntime } from './compatibility-version-policy.mts'; import { exampleCargoPolicies, exampleCargoReleaseVersionBindings, @@ -873,6 +874,21 @@ async function main(argv) { const transitions = releasePleaseWorktreeTransitions(ROOT, { prefix: PREFIX }); syncReleasePleaseBootstrapBoundary(changes, { write }); await syncCompatibilityVersions(changes, { write, transitions }); + if ( + transitions.some(({ product }) => product === 'oliphaunt-wasix-ts') && + (write || changes.length === 0) + ) { + const sdk = readJsonObject(path.join(ROOT, 'src/wasix/sdks/ts/package.json')).oliphaunt; + const addon = readJsonObject(path.join(ROOT, 'src/wasix/node-addon/package.json')).oliphaunt; + requireMatchingWasixRuntime( + { + runtimeVersion: sdk.runtimeVersion, + napiVersion: sdk.wasixNapiVersion, + napiRuntimeVersion: addon.runtimeVersion, + }, + { prefix: PREFIX }, + ); + } syncExtensionRegistryMetadata(changes, { write }); await syncNativeToolsOptionalDependencies(changes, { write, transitions }); syncElectronExampleDependencies(changes, { write }); From affd8a8be387b64a00b6bd9253070a542b14f26d Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 5 Oct 2026 02:13:13 +0000 Subject: [PATCH 2/6] fix(release): model embedded inputs and preserve SDK dependency pins --- src/docs/maintainers/release.md | 27 +++ src/native/broker/release.toml | 1 + src/native/node-addon/release.toml | 1 + src/native/sdks/kotlin/release.toml | 1 + src/native/sdks/react-native/moon.yml | 13 ++ .../sdks/react-native/tools/check-package.mts | 5 +- .../react-native/tools/check-package.test.mts | 16 +- .../tools/stage-release-artifacts.mts | 23 ++- src/native/sdks/swift/moon.yml | 5 +- src/native/sdks/swift/release.toml | 1 + .../sdks/swift/tools/ios-carrier-manifest.mts | 21 ++- .../swift/tools/ios-carrier-manifest.test.mts | 56 ++++++ .../swift/tools/pinned-native-carrier.mts | 71 +++++++ .../tools/pinned-native-carrier.test.mts | 70 +++++++ .../swift/tools/stage-release-artifacts.mts | 22 ++- .../swift/tools/swift-carrier-resolver.mts | 7 +- src/wasix/node-addon/release.toml | 13 +- .../runtime/crates/assets/build-support.rs | 54 ++++-- ...iboliphaunt-wasix-cargo-artifacts.test.mts | 72 ++++++++ src/wasix/runtime/tools/test-packaging.sh | 12 +- tools/ci/ci-plan-node-products.test.mts | 16 +- .../packaging/cargo-package-test-closure.mts | 6 +- .../cargo-package-test-closure.test.mts | 36 +++- .../release/independent-version-pins.test.mts | 90 ++++++++- tools/release/moon.yml | 5 + .../release/normal-publication-plan.test.mts | 25 ++- .../prepare-release-candidate.test.mts | 106 ++++++++++- tools/release/product-version.mts | 9 +- tools/release/release-graph.mts | 173 +++++++++++++++++- 29 files changed, 867 insertions(+), 90 deletions(-) create mode 100644 src/native/sdks/swift/tools/pinned-native-carrier.mts create mode 100644 src/native/sdks/swift/tools/pinned-native-carrier.test.mts diff --git a/src/docs/maintainers/release.md b/src/docs/maintainers/release.md index 86471397f..b205c92fb 100644 --- a/src/docs/maintainers/release.md +++ b/src/docs/maintainers/release.md @@ -155,6 +155,17 @@ contrib inputs may select both runtime owners because those source files are physically bundled into both products. No Moon dependency edge creates another release candidate. +Binary products declare `embedded_cargo_manifests` in their `release.toml`. +Release planning follows those manifests' local runtime, build, and target +dependencies through independent product boundaries and includes their Rust +sources and build scripts. It excludes development dependencies and prose. +The workspace Cargo manifest and lockfile conservatively select these binaries, +since dependency resolution and build settings also determine their shipped bytes. +This covers both Node addons, the native broker, and Swift/Kotlin native +bindings. Generated assets use `embedded_payload_products`: changes to an +embedded producer's sources, pins, or recipes also select the embedding binary. +Source-only facades and dynamically loaded runtimes retain independent releases. + Moon `production` and `peer` edges describe source and qualification impact. Product-local `compatibility_versions` describe the exact published product versions a carrier consumes. A native runtime can therefore be published @@ -170,6 +181,22 @@ its exact product tag and registry/GitHub carriers. Selecting a newer dependency does not satisfy an older consumer pin. Structured release-commit verification also rejects source changes hidden inside compatibility-only edits. +Source qualification uses current workspace producers and identifies the +runtime actually compiled. If those producers differ from immutable release +pins, npm fixtures carry `qualificationOnly` and publication rejects them. +Release package tests retain their declared pins: Cargo uses a local source +patch only at the matching exact version and otherwise resolves the published +dependency. A WASIX TypeScript release must pair the portable runtime with an +addon embedding the same runtime version. +WASIX source builds resolve local extension archive versions from each staged +extension product manifest. External extension versions are independent of the +runtime; contrib manifests carry their owning runtime's version. Cargo tracks +those manifests so a changed extension identity refreshes its embedded bytes. +Swift and React Native carrier staging also retains the exact native pin. +React Native resolves that pin through its declared Swift release, including +immutable historical metadata when Swift has advanced. Older runtime pins use +verified published Apple archives; current pins require same-run producer assets. + PR CI recognizes generated `chore(release):` changes only on the generated Release Please branch. Before merge it requires the release commit's parent to equal the exact base SHA, derives the product set from the manifest diff, and diff --git a/src/native/broker/release.toml b/src/native/broker/release.toml index 11f90a94f..f0f23e1f6 100644 --- a/src/native/broker/release.toml +++ b/src/native/broker/release.toml @@ -14,6 +14,7 @@ registry_packages = [ "npm:@oliphaunt/broker-win32-x64-msvc", ] release_artifacts = ["broker-helper-binary", "cargo-crate"] +embedded_cargo_manifests = ["src/native/broker/Cargo.toml"] [compatibility_versions.broker_native_bindings] source_product = "liboliphaunt-native-bindings" diff --git a/src/native/node-addon/release.toml b/src/native/node-addon/release.toml index 6f6477d69..82cc190b9 100644 --- a/src/native/node-addon/release.toml +++ b/src/native/node-addon/release.toml @@ -9,6 +9,7 @@ registry_packages = [ "npm:@oliphaunt/node-direct-win32-x64-msvc", ] release_artifacts = ["node-api-prebuilds", "npm-optional-platform-packages"] +embedded_cargo_manifests = ["src/native/node-addon/Cargo.toml"] [compatibility_versions.oliphaunt-node-direct-liboliphaunt] source_product = "liboliphaunt-native" diff --git a/src/native/sdks/kotlin/release.toml b/src/native/sdks/kotlin/release.toml index c99fd0097..ae50d424c 100644 --- a/src/native/sdks/kotlin/release.toml +++ b/src/native/sdks/kotlin/release.toml @@ -15,6 +15,7 @@ release_artifacts = [ "maven-publication", "runtime-assets-external", ] +embedded_cargo_manifests = ["src/native/mobile-bindings/Cargo.toml"] [compatibility_versions.oliphaunt-kotlin-liboliphaunt] source_product = "liboliphaunt-native" diff --git a/src/native/sdks/react-native/moon.yml b/src/native/sdks/react-native/moon.yml index afb73fa9a..165814cf5 100644 --- a/src/native/sdks/react-native/moon.yml +++ b/src/native/sdks/react-native/moon.yml @@ -121,7 +121,14 @@ tasks: - /src/native/sdks/react-native/tools/check-package.mts - /src/extensions/artifacts/packages/tools/contrib-carriers.mts - /src/native/sdks/swift/tools/ios-carrier-manifest.mts + - /src/native/sdks/swift/tools/pinned-native-carrier.mts + - /src/native/sdks/swift/tools/swift-carrier-resolver.mts + - /src/native/sdks/swift/tools/render_swiftpm_release_package.mts + - /src/native/sdks/swift/tools/swift-source-carrier-contract.mts - /tools/release/release-graph.mts + - /tools/release/product-version.mts + - /tools/release/with-product-history.sh + - /tools/release/release-history.mts - /tools/packaging/npm-package.mts - /src/native/sdks/react-native/tools/stage-release-artifacts.mts - /src/native/sdks/react-native/tools/stage-release-artifacts.sh @@ -184,6 +191,12 @@ tasks: - "/tools/dev/bun.sh" - /src/examples/native/react-native-expo/package.json - /tools/packaging/portable-archive.mts + - /src/native/sdks/swift/tools/ios-carrier-manifest.mts + - /src/native/sdks/swift/tools/swift-source-carrier-contract.mts + - /src/native/sdks/swift/tools/pinned-native-carrier.mts + - /src/native/sdks/swift/tools/swift-carrier-resolver.mts + - /src/native/sdks/swift/tools/render_swiftpm_release_package.mts + - /tools/release/*.{mts,sh} - project: oliphaunt-query-ts group: sources - "@group(bun-workspace)" diff --git a/src/native/sdks/react-native/tools/check-package.mts b/src/native/sdks/react-native/tools/check-package.mts index 54a9cef87..f68f1d745 100644 --- a/src/native/sdks/react-native/tools/check-package.mts +++ b/src/native/sdks/react-native/tools/check-package.mts @@ -18,7 +18,7 @@ import { SOURCE_ONLY_NPM_PROFILES, assertSourceOnlyNpmArchive, } from '../../../../../tools/packaging/source-only-sdk-package.mts'; -import { productCompatibilityVersion } from '../../../../../tools/release/release-graph.mts'; +import { productDependencyCompatibilityVersion } from '../../../../../tools/release/release-graph.mts'; /** * Prove that the selection-neutral Apple carrier users receive in the React @@ -92,7 +92,8 @@ export async function checkReactNativePackage(root) { validateReactNativePackagedCarrier({ artifact: tarball, evidence: readFileSync(carrierEvidence), - expectedNativeVersion: productCompatibilityVersion( + expectedNativeVersion: productDependencyCompatibilityVersion( + 'oliphaunt-react-native', 'oliphaunt-swift', 'liboliphaunt-native', PREFIX, diff --git a/src/native/sdks/react-native/tools/check-package.test.mts b/src/native/sdks/react-native/tools/check-package.test.mts index aba8e84c8..77d49c495 100644 --- a/src/native/sdks/react-native/tools/check-package.test.mts +++ b/src/native/sdks/react-native/tools/check-package.test.mts @@ -6,6 +6,7 @@ import { stageArtifacts } from './stage-release-artifacts.mts'; import { iosBaseLegalMetadata } from '../../swift/tools/ios-carrier-manifest.mts'; import assert from 'node:assert/strict'; import { validateReactNativePackagedCarrier } from './check-package.mts'; +import { productDependencyCompatibilityVersion } from '../../../../../tools/release/release-graph.mts'; function selectionNeutralCarrier(version = '1.2.3') { const product = 'liboliphaunt-native'; @@ -92,14 +93,15 @@ test('binds the React Native npm carrier bytes to selection-neutral staged evide ); }); -test('package staging accepts frozen current iOS metadata and rejects stale or corrupt metadata', () => { +test('package staging accepts frozen pinned iOS metadata and rejects mismatched or corrupt metadata', async () => { const root = mkdtempSync(path.join(tmpdir(), 'rn-frozen-carrier-')); const previous = process.env.OLIPHAUNT_REACT_NATIVE_IOS_BASE_CARRIER; try { - const version = readFileSync( - new URL('../../../runtime/VERSION', import.meta.url), - 'utf8', - ).trim(); + const version = productDependencyCompatibilityVersion( + 'oliphaunt-react-native', + 'oliphaunt-swift', + 'liboliphaunt-native', + ); const carrier = selectionNeutralCarrier(version); const frozen = path.join(root, 'frozen.json'); const work = path.join(root, 'work'); @@ -108,7 +110,7 @@ test('package staging accepts frozen current iOS metadata and rejects stale or c writeFileSync(path.join(work, 'package/package.json'), '{}'); writeFileSync(frozen, JSON.stringify(carrier)); process.env.OLIPHAUNT_REACT_NATIVE_IOS_BASE_CARRIER = frozen; - stageArtifacts(artifacts, work); + await stageArtifacts(artifacts, work); const evidence = readFileSync( path.join(artifacts, 'ios-carriers/oliphaunt-react-native-ios-carriers.json'), 'utf8', @@ -129,7 +131,7 @@ test('package staging accepts frozen current iOS metadata and rejects stale or c const invalid = structuredClone(carrier); mutate(invalid); writeFileSync(frozen, JSON.stringify(invalid)); - assert.throws(() => stageArtifacts(artifacts, work)); + await assert.rejects(stageArtifacts(artifacts, work)); } } finally { if (previous === undefined) delete process.env.OLIPHAUNT_REACT_NATIVE_IOS_BASE_CARRIER; diff --git a/src/native/sdks/react-native/tools/stage-release-artifacts.mts b/src/native/sdks/react-native/tools/stage-release-artifacts.mts index 3e25a28e4..5668398e0 100644 --- a/src/native/sdks/react-native/tools/stage-release-artifacts.mts +++ b/src/native/sdks/react-native/tools/stage-release-artifacts.mts @@ -6,19 +6,34 @@ import { buildIosCarrierManifest, } from '../../swift/tools/ios-carrier-manifest.mts'; import { fail, requireDir } from '../../../../../tools/packaging/staging.mts'; +import { productDependencyCompatibilityVersion } from '../../../../../tools/release/release-graph.mts'; +import { pinnedNativeCarrierDirectory } from '../../swift/tools/pinned-native-carrier.mts'; -export function stageArtifacts(artifactRoot, workRoot) { +export async function stageArtifacts(artifactRoot, workRoot) { const releasePackageDir = path.join(workRoot, 'package'); requireDir(releasePackageDir); - const assetDir = process.env.OLIPHAUNT_REACT_NATIVE_IOS_RELEASE_ASSET_DIR; + const producerAssetDir = process.env.OLIPHAUNT_REACT_NATIVE_IOS_RELEASE_ASSET_DIR; const baseCarrierManifest = process.env.OLIPHAUNT_REACT_NATIVE_IOS_BASE_CARRIER || undefined; - if (!assetDir && !baseCarrierManifest) { + if (!producerAssetDir && !baseCarrierManifest) { fail( 'oliphaunt-react-native package artifacts require OLIPHAUNT_REACT_NATIVE_IOS_RELEASE_ASSET_DIR', ); } + const nativeVersion = productDependencyCompatibilityVersion( + 'oliphaunt-react-native', + 'oliphaunt-swift', + 'liboliphaunt-native', + ); + const assetDir = baseCarrierManifest + ? undefined + : await pinnedNativeCarrierDirectory({ + version: nativeVersion, + assetDir: producerAssetDir, + workRoot, + }); const carrier = buildIosCarrierManifest({ baseAssetDir: assetDir, + baseRuntimeVersion: nativeVersion, baseCarrierManifest, extensionManifests: [], }); @@ -47,5 +62,5 @@ export function stageArtifacts(artifactRoot, workRoot) { if (import.meta.main) { if (process.argv.length !== 4) throw new Error('usage: stage-release-artifacts.mts '); - stageArtifacts(path.resolve(process.argv[2]), path.resolve(process.argv[3])); + await stageArtifacts(path.resolve(process.argv[2]), path.resolve(process.argv[3])); } diff --git a/src/native/sdks/swift/moon.yml b/src/native/sdks/swift/moon.yml index ddbc36c2d..632ffcb7e 100644 --- a/src/native/sdks/swift/moon.yml +++ b/src/native/sdks/swift/moon.yml @@ -197,6 +197,9 @@ tasks: - "/src/native/sdks/swift/tools/check-package.mts" - "/src/extensions/artifacts/packages/tools/contrib-carriers.mts" - "/src/native/sdks/swift/tools/ios-carrier-manifest.mts" + - "/src/native/sdks/swift/tools/pinned-native-carrier.mts" + - "/src/native/sdks/swift/tools/swift-carrier-resolver.mts" + - "/src/native/sdks/swift/tools/render_swiftpm_release_package.mts" - "/src/native/sdks/swift/tools/prepare-swift-release-consumer.mts" - "/tools/release/release-graph.mts" - "/tools/packaging/staging.mts" @@ -219,7 +222,7 @@ tasks: set -eu bash src/native/sdks/swift/tools/swift-carrier-resolver.test.sh bun src/native/sdks/swift/tools/extension-resource-inventory.test.mts - bash tools/dev/bun.sh test ./src/native/sdks/swift/tools/ios-carrier-manifest.test.mts ./src/native/sdks/swift/tools/render_swiftpm_release_package.test.mts ./src/native/sdks/swift/tools/check-package.test.mts ./src/native/sdks/swift/tools/prepare-swift-release-consumer.test.mts ./src/native/sdks/swift/tools/swift-extension-release-consumer-inputs.test.mts + bash tools/dev/bun.sh test ./src/native/sdks/swift/tools/ios-carrier-manifest.test.mts ./src/native/sdks/swift/tools/pinned-native-carrier.test.mts ./src/native/sdks/swift/tools/render_swiftpm_release_package.test.mts ./src/native/sdks/swift/tools/check-package.test.mts ./src/native/sdks/swift/tools/prepare-swift-release-consumer.test.mts ./src/native/sdks/swift/tools/swift-extension-release-consumer-inputs.test.mts inputs: - "@group(release-target-contract)" - "@group(package-test-metadata)" diff --git a/src/native/sdks/swift/release.toml b/src/native/sdks/swift/release.toml index a6f411e01..6f95f133b 100644 --- a/src/native/sdks/swift/release.toml +++ b/src/native/sdks/swift/release.toml @@ -8,6 +8,7 @@ release_artifacts = [ "apple-assets-via-liboliphaunt", "exact-extension-xcframework-inputs", ] +embedded_cargo_manifests = ["src/native/mobile-bindings/Cargo.toml"] [compatibility_versions.oliphaunt-swift-liboliphaunt] source_product = "liboliphaunt-native" diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.mts index e785d7225..d3c32c635 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.mts @@ -471,9 +471,16 @@ function carrierEnvelope({ file, tag, repository, localUrls }) { }; } -function baseCarrier({ baseAssetDir, repository, localUrls, verifyMembers, archiveCache }) { +function baseCarrier({ + baseAssetDir, + baseRuntimeVersion, + repository, + localUrls, + verifyMembers, + archiveCache, +}) { const product = 'liboliphaunt-native'; - const version = currentProductVersionSync(product, 'ios-carrier-manifest'); + const version = stableVersion(baseRuntimeVersion, 'base runtime version'); const tag = `${tagPrefix(product, 'ios-carrier-manifest')}${version}`; const rows = [ { @@ -516,7 +523,7 @@ function baseCarrier({ baseAssetDir, repository, localUrls, verifyMembers, archi }; } -function frozenBaseCarrier(file) { +function frozenBaseCarrier(file, baseRuntimeVersion) { let manifest; try { manifest = JSON.parse( @@ -528,7 +535,7 @@ function frozenBaseCarrier(file) { const base = manifest?.schema === IOS_CARRIER_SCHEMA ? manifest.base : manifest; const legal = manifest?.schema === IOS_CARRIER_SCHEMA ? manifest.legal?.base : manifest?.legal; const product = 'liboliphaunt-native'; - const version = currentProductVersionSync(product, 'ios-carrier-manifest'); + const version = stableVersion(baseRuntimeVersion, 'base runtime version'); const tag = `${tagPrefix(product, 'ios-carrier-manifest')}${version}`; if ( base?.product !== product || @@ -536,7 +543,7 @@ function frozenBaseCarrier(file) { base.tag !== tag || !Array.isArray(base.assets) ) { - throw error(`${file} does not freeze the current ${product} base carrier`); + throw error(`${file} does not freeze ${product} ${version} base carrier`); } const expectedRoles = ['base-xcframework', 'runtime-resources']; if (JSON.stringify(base.assets.map(({ role }) => role)) !== JSON.stringify(expectedRoles)) { @@ -1119,6 +1126,7 @@ export function discoveredExtensionManifests(root) { export function buildIosCarrierManifest({ baseAssetDir = path.join(ROOT, 'target/liboliphaunt/release-assets'), + baseRuntimeVersion = currentProductVersionSync('liboliphaunt-native', 'ios-carrier-manifest'), baseCarrierManifest = undefined, extensionManifests = discoveredExtensionManifests(path.join(ROOT, 'target/extension-artifacts')), repository = DEFAULT_REPOSITORY, @@ -1132,11 +1140,12 @@ export function buildIosCarrierManifest({ ? baseCarrier({ archiveCache, baseAssetDir: path.resolve(baseAssetDir), + baseRuntimeVersion, repository, localUrls, verifyMembers, }) - : frozenBaseCarrier(baseCarrierManifest); + : frozenBaseCarrier(baseCarrierManifest, baseRuntimeVersion); const { base, legal: baseLegal } = frozenBase; const documents = extensionManifests.map((file) => { const document = extensionCarriers(path.resolve(file), { diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts index 2529f8e74..9ce665383 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts @@ -135,6 +135,62 @@ function writeManifest(root, product, body) { return file; } +test('an independently pinned base carrier uses the pinned release and rejects current runtime assets', async () => { + const root = mkdtempSync(path.join(ROOT, 'target', 'ios-pinned-carrier-test-')); + const version = '0.1.0'; + try { + await archive( + root, + `liboliphaunt-${version}-apple-spm-xcframework.zip`, + 'liboliphaunt.xcframework', + 'zip', + { + insideMember: true, + profile: 'native-runtime', + }, + ); + await archive( + root, + `liboliphaunt-${version}-runtime-resources-ios-datum64.tar.gz`, + 'oliphaunt', + 'tar.gz', + { + insideMember: false, + profile: 'native-runtime-resources', + }, + ); + const carrier = buildIosCarrierManifest({ + baseAssetDir: root, + baseRuntimeVersion: version, + extensionManifests: [], + }); + assert.equal(carrier.base.version, version); + assert.equal(carrier.base.tag, `liboliphaunt-native-v${version}`); + for (const row of carrier.base.assets) + assert.ok(row.url.includes(`/liboliphaunt-native-v${version}/liboliphaunt-${version}-`)); + assert.throws( + () => buildIosCarrierManifest({ baseAssetDir: root, extensionManifests: [] }), + /missing|does not exist/, + ); + const frozen = path.join(root, 'carrier.json'); + writeFileSync(frozen, JSON.stringify(carrier)); + assert.deepEqual( + buildIosCarrierManifest({ + baseCarrierManifest: frozen, + baseRuntimeVersion: version, + extensionManifests: [], + }), + carrier, + ); + assert.throws( + () => buildIosCarrierManifest({ baseCarrierManifest: frozen, extensionManifests: [] }), + /does not freeze/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('produces exact local and GitHub carrier envelopes ', async () => { mkdirSync(path.join(ROOT, 'target'), { recursive: true }); const root = mkdtempSync(path.join(ROOT, 'target', 'ios-carrier-test-')); diff --git a/src/native/sdks/swift/tools/pinned-native-carrier.mts b/src/native/sdks/swift/tools/pinned-native-carrier.mts new file mode 100644 index 000000000..007b4f86d --- /dev/null +++ b/src/native/sdks/swift/tools/pinned-native-carrier.mts @@ -0,0 +1,71 @@ +import { copyFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { currentProductVersionSync } from '../../../../../tools/release/release-artifact-targets.mts'; +import { fetchText } from './render_swiftpm_release_package.mts'; +import { materialize } from './swift-carrier-resolver.mts'; + +// Source builds use the same-run producer for the current runtime. Independent +// SDK releases keep their exact older runtime and verify its published bytes. +export async function pinnedNativeCarrierDirectory({ + version, + assetDir, + workRoot, + fetchImpl = fetch, +}) { + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(version)) { + throw new Error(`invalid native runtime pin: ${version}`); + } + if (version === currentProductVersionSync('liboliphaunt-native', 'pinned-native-carrier')) { + if (!assetDir) throw new Error(`native runtime ${version} requires same-run producer assets`); + return assetDir; + } + const tag = `liboliphaunt-native-v${version}`; + const api = 'https://api.github.com/repos/f0rr0/oliphaunt'; + const readJson = async (url) => JSON.parse(await fetchText(url, { fetchImpl })); + const release = await readJson(`${api}/releases/tags/${tag}`); + if ( + release.tag_name !== tag || + release.draft !== false || + release.prerelease !== false || + !Number.isSafeInteger(release.id) || + release.id <= 0 + ) { + throw new Error(`native runtime pin ${version} requires a published stable release ${tag}`); + } + const assets = []; + for (let page = 1; ; page += 1) { + if (page > 10) throw new Error(`${tag} has too many release assets`); + const rows = await readJson(`${api}/releases/${release.id}/assets?per_page=100&page=${page}`); + if (!Array.isArray(rows) || rows.length > 100) + throw new Error(`${tag} has invalid release assets`); + assets.push(...rows); + if (rows.length < 100) break; + } + const directory = path.join(workRoot, 'pinned-native-carrier', version); + mkdirSync(directory, { recursive: true }); + for (const name of [ + `liboliphaunt-${version}-apple-spm-xcframework.zip`, + `liboliphaunt-${version}-runtime-resources-ios-datum64.tar.gz`, + ]) { + const matches = assets.filter((row) => row.name === name); + const row = matches[0]; + const url = `https://github.com/f0rr0/oliphaunt/releases/download/${tag}/${name}`; + if ( + matches.length !== 1 || + row.browser_download_url !== url || + !/^sha256:[a-f0-9]{64}$/u.test(row.digest) || + !Number.isSafeInteger(row.size) || + row.size <= 0 || + row.size > 512 * 1024 * 1024 + ) { + throw new Error(`${tag} must publish one checksummed ${name}`); + } + const file = await materialize( + { name, url, bytes: row.size, sha256: row.digest.slice(7) }, + path.join(workRoot, 'pinned-native-carrier-cache'), + { fetchImpl }, + ); + copyFileSync(file, path.join(directory, name)); + } + return directory; +} diff --git a/src/native/sdks/swift/tools/pinned-native-carrier.test.mts b/src/native/sdks/swift/tools/pinned-native-carrier.test.mts new file mode 100644 index 000000000..95ccc576b --- /dev/null +++ b/src/native/sdks/swift/tools/pinned-native-carrier.test.mts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { currentProductVersionSync } from '../../../../../tools/release/release-artifact-targets.mts'; +import { pinnedNativeCarrierDirectory } from './pinned-native-carrier.mts'; + +test('current runtime requires same-run assets and never falls back to a published release', async () => { + const version = currentProductVersionSync('liboliphaunt-native', 'pinned-native-carrier.test'); + const fetchImpl = () => { + throw new Error('unexpected download'); + }; + assert.equal( + await pinnedNativeCarrierDirectory({ version, assetDir: '/producer', fetchImpl }), + '/producer', + ); + await assert.rejects(pinnedNativeCarrierDirectory({ version, fetchImpl }), /same-run producer/); +}); + +test('older runtime pins stage exact published bytes, including paginated assets and cache verification', async () => { + const workRoot = mkdtempSync(path.join(os.tmpdir(), 'pinned-native-carrier-')); + const version = '0.1.0'; + const tag = `liboliphaunt-native-v${version}`; + const names = [ + `liboliphaunt-${version}-apple-spm-xcframework.zip`, + `liboliphaunt-${version}-runtime-resources-ios-datum64.tar.gz`, + ]; + const assets = names.map((name) => ({ + name, + size: Buffer.byteLength(name), + digest: `sha256:${createHash('sha256').update(name).digest('hex')}`, + browser_download_url: `https://github.com/f0rr0/oliphaunt/releases/download/${tag}/${name}`, + })); + let corrupt = false; + let downloads = 0; + const fetchImpl = async (input) => { + const url = String(input); + if (url.includes('/releases/tags/')) + return Response.json({ id: 23, tag_name: tag, draft: false, prerelease: false }); + if (new URL(url).searchParams.get('page') === '1') + return Response.json(Array.from({ length: 100 }, (_, id) => ({ name: `other-${id}` }))); + if (new URL(url).searchParams.get('page') === '2') return Response.json(assets); + const row = assets.find((asset) => asset.browser_download_url === url); + assert.ok(row, url); + downloads += 1; + const response = new Response(corrupt ? 'x'.repeat(row.size) : row.name); + Object.defineProperty(response, 'url', { value: url }); + return response; + }; + try { + const options = { version, assetDir: '/current-producer', workRoot, fetchImpl }; + const directory = await pinnedNativeCarrierDirectory(options); + for (const name of names) assert.equal(readFileSync(path.join(directory, name), 'utf8'), name); + assert.equal(downloads, 2); + await pinnedNativeCarrierDirectory(options); + assert.equal(downloads, 2); + rmSync(path.join(workRoot, 'pinned-native-carrier-cache'), { recursive: true }); + corrupt = true; + await assert.rejects(pinnedNativeCarrierDirectory(options), /checksum mismatch/); + assets[0].browser_download_url = 'https://example.com/other.zip'; + await assert.rejects(pinnedNativeCarrierDirectory(options), /must publish one checksummed/); + assets[0].browser_download_url = `https://github.com/f0rr0/oliphaunt/releases/download/${tag}/${names[0]}`; + assets[0].digest = null; + await assert.rejects(pinnedNativeCarrierDirectory(options), /must publish one checksummed/); + } finally { + rmSync(workRoot, { recursive: true, force: true }); + } +}); diff --git a/src/native/sdks/swift/tools/stage-release-artifacts.mts b/src/native/sdks/swift/tools/stage-release-artifacts.mts index 29ae70d11..badaf8d3e 100644 --- a/src/native/sdks/swift/tools/stage-release-artifacts.mts +++ b/src/native/sdks/swift/tools/stage-release-artifacts.mts @@ -16,6 +16,7 @@ import { } from '../../../../../tools/packaging/release-notices.mts'; import { productCompatibilityVersion } from '../../../../../tools/release/release-graph.mts'; import { validateSwiftSourceReleaseContract } from './swift-source-carrier-contract.mts'; +import { pinnedNativeCarrierDirectory } from './pinned-native-carrier.mts'; import { ROOT, copyDirContents, @@ -34,10 +35,20 @@ export async function stageArtifacts(artifactRoot, workRoot) { requireFile(swiftSourceArchive); const stagedSourceArchive = path.join(artifactRoot, 'Oliphaunt-source.zip'); copyFileSync(swiftSourceArchive, stagedSourceArchive); - const assetDir = process.env.OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR; - if (!assetDir) { + const producerAssetDir = process.env.OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR; + if (!producerAssetDir) { fail('oliphaunt-swift package artifacts require OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR'); } + const nativeVersion = productCompatibilityVersion( + 'oliphaunt-swift', + 'liboliphaunt-native', + PREFIX, + ); + const assetDir = await pinnedNativeCarrierDirectory({ + version: nativeVersion, + assetDir: producerAssetDir, + workRoot, + }); await renderSwiftpmReleasePackage([ '--asset-dir', assetDir, @@ -53,6 +64,7 @@ export async function stageArtifacts(artifactRoot, workRoot) { assertReleaseNoticesInDirectory(releaseTree); const carrier = buildIosCarrierManifest({ baseAssetDir: assetDir, + baseRuntimeVersion: nativeVersion, extensionManifests: [], }); const carrierFile = path.join(releaseTree, 'src/sdks/swift/Carriers', IOS_CARRIER_FILENAME); @@ -96,11 +108,7 @@ export async function stageArtifacts(artifactRoot, workRoot) { try { validateSwiftSourceReleaseContract({ carrier, - expectedNativeVersion: productCompatibilityVersion( - 'oliphaunt-swift', - 'liboliphaunt-native', - PREFIX, - ), + expectedNativeVersion: nativeVersion, label: `${rel(artifactRoot)} source release`, manifestText: manifest, }); diff --git a/src/native/sdks/swift/tools/swift-carrier-resolver.mts b/src/native/sdks/swift/tools/swift-carrier-resolver.mts index cb9d9d7fb..b0f3eb594 100644 --- a/src/native/sdks/swift/tools/swift-carrier-resolver.mts +++ b/src/native/sdks/swift/tools/swift-carrier-resolver.mts @@ -535,7 +535,7 @@ function byteLimitTransform(limit, label) { }, }); } -async function materialize(row, cacheDir, { offline }) { +export async function materialize(row, cacheDir, { offline = false, fetchImpl = fetch } = {}) { const directory = path.join(cacheDir, 'objects'); const output = path.join(directory, `${row.sha256}-${row.name}`); await fs.mkdir(directory, { recursive: true }); @@ -557,7 +557,10 @@ async function materialize(row, cacheDir, { offline }) { } await fs.copyFile(source, temporary, fsConstants.COPYFILE_EXCL); } else { - const response = await fetch(url, { redirect: 'follow' }); + const response = await fetchImpl(url, { + redirect: 'follow', + signal: AbortSignal.timeout(120_000), + }); if (!response.ok || !response.body || new URL(response.url).protocol !== 'https:') fail(`download failed for ${row.url}`); await pipeline( diff --git a/src/wasix/node-addon/release.toml b/src/wasix/node-addon/release.toml index 0e00cdae0..0852f5816 100644 --- a/src/wasix/node-addon/release.toml +++ b/src/wasix/node-addon/release.toml @@ -9,17 +9,8 @@ registry_packages = [ "npm:@oliphaunt/wasix-napi-win32-x64-msvc", ] release_artifacts = ["node-api-prebuilds", "npm-optional-platform-packages"] -shared_source_paths = [ - "src/wasix/sdks/rust/src", - "src/wasix/sdks/rust/build.rs", - "src/wasix/sdks/rust/Cargo.toml", - "src/wasix/pgwire-server/src", - "src/wasix/pgwire-server/Cargo.toml", - "src/query/rust/src", - "src/query/rust/Cargo.toml", - "src/wasix/runtime/assets", - "src/wasix/runtime/crates", -] +embedded_cargo_manifests = ["src/wasix/node-addon/Cargo.toml"] +embedded_payload_products = ["liboliphaunt-wasix", "postgres-tools-wasix", "database-resources"] [compatibility_versions.oliphaunt-wasix-napi-runtime] source_product = "liboliphaunt-wasix" diff --git a/src/wasix/runtime/crates/assets/build-support.rs b/src/wasix/runtime/crates/assets/build-support.rs index 9c123beca..e26af1d7b 100644 --- a/src/wasix/runtime/crates/assets/build-support.rs +++ b/src/wasix/runtime/crates/assets/build-support.rs @@ -681,16 +681,12 @@ fn find_local_extension_product_root(root: &Path, package: ExtensionPackage) -> .find(|candidate| candidate.join("extension-artifacts.json").is_file()) } -fn local_extension_aot_package(package: ExtensionPackage) -> Option { - let root = PathBuf::from(env::var_os("OLIPHAUNT_WASIX_EXTENSION_ARTIFACT_ROOT")?); - let product_root = find_local_extension_product_root(&root, package).unwrap_or_else(|| { - panic!( - "local extension artifact root {} has no manifest for {}", - root.display(), - package.product, - ) - }); +fn local_extension_artifact_manifest( + product_root: &Path, + package: ExtensionPackage, +) -> serde_json::Value { let product_manifest = product_root.join("extension-artifacts.json"); + println!("cargo:rerun-if-changed={}", product_manifest.display()); let product_value: serde_json::Value = serde_json::from_str( &fs::read_to_string(&product_manifest).unwrap_or_else(|error| { panic!( @@ -713,6 +709,35 @@ fn local_extension_aot_package(package: ExtensionPackage) -> Option>(); + assert!( + parts.len() == 3 + && parts.iter().all(|part| { + !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_digit()) + && (part.len() == 1 || !part.starts_with('0')) + }), + "local extension artifact manifest {} has invalid product version {version}", + product_manifest.display(), + ); + product_value +} + +fn local_extension_aot_package(package: ExtensionPackage) -> Option { + let root = PathBuf::from(env::var_os("OLIPHAUNT_WASIX_EXTENSION_ARTIFACT_ROOT")?); + let product_root = find_local_extension_product_root(&root, package).unwrap_or_else(|| { + panic!( + "local extension artifact root {} has no manifest for {}", + root.display(), + package.product, + ) + }); + let product_manifest = product_root.join("extension-artifacts.json"); + let product_value = local_extension_artifact_manifest(&product_root, package); let schema = product_value .get("schema") .and_then(serde_json::Value::as_str) @@ -895,7 +920,6 @@ fn local_extension_aot_package(package: ExtensionPackage) -> Option, package: ExtensionPackage, ) -> Option { - let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); - let archive_name = format!("{}-{version}-wasix-portable.tar.zst", package.product); let roots = if let Some(path) = env::var_os("OLIPHAUNT_WASIX_EXTENSION_ARTIFACT_ROOT") { // An explicit root takes precedence over workspace and package assets. vec![PathBuf::from(path)] @@ -952,6 +974,14 @@ fn find_local_extension_archive( for root in roots { for product_root in local_extension_product_roots(&root, package.product) { + if !product_root.join("extension-artifacts.json").is_file() { + continue; + } + let manifest = local_extension_artifact_manifest(&product_root, package); + let version = manifest["version"] + .as_str() + .expect("validated product version"); + let archive_name = format!("{}-{version}-wasix-portable.tar.zst", package.product); for candidate in [ product_root .join("member-assets") diff --git a/src/wasix/runtime/tools/package-liboliphaunt-wasix-cargo-artifacts.test.mts b/src/wasix/runtime/tools/package-liboliphaunt-wasix-cargo-artifacts.test.mts index a5329a5ee..1f5b6e0b3 100644 --- a/src/wasix/runtime/tools/package-liboliphaunt-wasix-cargo-artifacts.test.mts +++ b/src/wasix/runtime/tools/package-liboliphaunt-wasix-cargo-artifacts.test.mts @@ -20,6 +20,7 @@ import { extensionReleaseProduct, extensionReleaseVersion, extensionSqlNames, + exactExtensionProducts, } from '../../../../tools/release/release-artifact-targets.mts'; import { extractArchiveMemberToFile, @@ -217,6 +218,77 @@ function aggregateFixture(root, { nestedOwner = false } = {}) { } describe('aggregate WASIX Cargo artifact packaging', () => { + test('runtime source builds use every external extension product version independently of the runtime', () => { + const root = path.join(scratch, 'independent-extension'); + const host = supportedRustcHostTriple(); + const targetId = Object.entries(AOT_TARGET_TRIPLES).find(([, triple]) => triple === host)[0]; + const features = []; + const assertions = []; + for (const [index, product] of exactExtensionProducts() + .filter((product) => product !== 'oliphaunt-extension-contrib-pg18') + .entries()) { + const [sqlName] = extensionSqlNames(product); + const version = `7.${index}.0`; + const productRoot = path.join(root, product); + const assets = path.join(productRoot, 'release-assets'); + mkdirSync(assets, { recursive: true }); + const archive = path.join(assets, `${product}-${version}-wasix-portable.tar.zst`); + const payload = `${sqlName}:independent-product-version`; + writeFileSync(archive, payload); + writeFileSync( + path.join(productRoot, 'extension-artifacts.json'), + JSON.stringify({ + schema: 'oliphaunt-extension-ci-artifacts-v1', + product, + version, + sqlName, + nativeModuleStem: sqlName === 'pg_hashids' ? sqlName : null, + }), + ); + features.push(`extension-${sqlName.replaceAll('_', '-')}`); + assertions.push( + `assert_eq!(liboliphaunt_wasix_portable::extension_archive(${JSON.stringify(sqlName)}).unwrap(), b${JSON.stringify(payload)});`, + ); + if (sqlName === 'pg_hashids') { + const directory = path.join(productRoot, 'wasix-aot', targetId); + mkdirSync(directory, { recursive: true }); + const artifact = path.join(directory, 'extension.bin.zst'); + writeFileSync(artifact, 'independent-extension-aot'); + writeFileSync( + path.join(directory, 'manifest.json'), + JSON.stringify({ + 'format-version': 1, + 'target-triple': host, + artifacts: [ + { + name: `extension:${sqlName}`, + path: path.basename(artifact), + sha256: sha256(artifact), + }, + ], + }), + ); + assertions.push( + `assert_eq!(liboliphaunt_wasix_portable::extension_aot_artifact_bytes(${JSON.stringify(host)}, "extension:pg_hashids").unwrap(), b"independent-extension-aot");`, + ); + } + } + const app = path.join(root, 'app'); + mkdirSync(path.join(app, 'src'), { recursive: true }); + writeFileSync( + path.join(app, 'Cargo.toml'), + `[package] +name = "wasix-independent-extension-proof" +version = "0.0.0" +edition = "2024" +[dependencies] +liboliphaunt-wasix-portable = { path = ${JSON.stringify(path.join(ROOT, 'src/wasix/runtime/crates/assets'))}, features = ${JSON.stringify(features)} } +[workspace] +`, + ); + writeFileSync(path.join(app, 'src/main.rs'), `fn main() { ${assertions.join('\n')} }\n`); + }); + test('runtime source build resolves runtime-owned contrib archives and AOT under the WASIX owner', { timeout: 180_000, }, () => { diff --git a/src/wasix/runtime/tools/test-packaging.sh b/src/wasix/runtime/tools/test-packaging.sh index 8fc96b151..719d33b2e 100644 --- a/src/wasix/runtime/tools/test-packaging.sh +++ b/src/wasix/runtime/tools/test-packaging.sh @@ -18,7 +18,7 @@ if bun "$contract" unsupported > /dev/null 2>&1; then fi bash tools/dev/bun.sh test ./src/wasix/runtime/tools node src/wasix/runtime/tools/wasix-runtime-npm.test-consumer.mts -for scenario in nested-owner aggregate; do +for scenario in nested-owner aggregate independent-extension; do root="$OLIPHAUNT_WASIX_PACKAGING_TEST_ROOT/$scenario" # Keep registry dependencies at the qualified workspace versions; this # disposable consumer only changes local carrier paths and feature selection. @@ -28,6 +28,16 @@ for scenario in nested-owner aggregate; do OLIPHAUNT_WASIX_EXTENSION_ARTIFACT_ROOT="$root" \ cargo run --locked --offline --manifest-path "$root/app/Cargo.toml" done +root="$OLIPHAUNT_WASIX_PACKAGING_TEST_ROOT/independent-extension" +manifest="$root/oliphaunt-extension-pg-hashids/extension-artifacts.json" +bun -e 'import fs from "node:fs"; const file = process.argv[1]; const value = JSON.parse(fs.readFileSync(file, "utf8")); value.version = "../invalid"; fs.writeFileSync(file, JSON.stringify(value));' "$manifest" +if CARGO_TARGET_DIR="$OLIPHAUNT_WASIX_PACKAGING_TEST_ROOT/cargo-target" \ + OLIPHAUNT_WASIX_EXTENSION_ARTIFACT_ROOT="$root" \ + cargo check --locked --offline --manifest-path "$root/app/Cargo.toml" > "$root/invalid-version.log" 2>&1; then + echo 'Invalid extension product version was accepted' >&2 + exit 1 +fi +rg -q 'invalid product version' "$root/invalid-version.log" root="$OLIPHAUNT_WASIX_PACKAGING_TEST_ROOT/aggregate" chunk="$(find "$root/work/cargo-package-sources/oliphaunt-extension-contrib-pg18-wasix-part-001/payload" -type f -print -quit)" printf 'corrupt' >> "$chunk" diff --git a/tools/ci/ci-plan-node-products.test.mts b/tools/ci/ci-plan-node-products.test.mts index 344ee7d21..d37d65c96 100644 --- a/tools/ci/ci-plan-node-products.test.mts +++ b/tools/ci/ci-plan-node-products.test.mts @@ -569,19 +569,29 @@ test('combined JavaScript SDK and WASIX N-API changes release only changed produ assert.deepEqual(result.releaseProducts, ['oliphaunt-js', 'oliphaunt-wasix-napi']); }); -test('shared contrib source releases only its two runtime owners', () => { +test('shared contrib source releases its runtime owners and the addon embedding WASIX payloads', () => { const release = buildPlan( GRAPH, ['src/extensions/contrib/postgres18.toml'], 'ci-plan-node-products.test.mts', ); - assert.deepEqual(release.directProducts, ['liboliphaunt-native', 'liboliphaunt-wasix']); - assert.deepEqual(release.releaseProducts, ['liboliphaunt-native', 'liboliphaunt-wasix']); + assert.deepEqual(release.directProducts, [ + 'liboliphaunt-native', + 'liboliphaunt-wasix', + 'oliphaunt-wasix-napi', + ]); + assert.deepEqual(release.releaseProducts, [ + 'liboliphaunt-native', + 'liboliphaunt-wasix', + 'oliphaunt-wasix-napi', + ]); const plan = planForReleaseProducts(release.releaseProducts, 'c'.repeat(40)); const contrib = contribCarrierDescriptor(); assert(plan.extension_package_products.includes(contrib.artifactProduct)); assert(plan.tasks.includes('native-extension-lifecycle:lifecycle')); assert(plan.tasks.includes('extension-artifacts-wasix:build-target')); + assert(plan.tasks.includes('oliphaunt-wasix-napi:build-release-assets')); + assert(plan.tasks.includes('oliphaunt-wasix-napi:finalize-release-assets')); for (const sql of ['hstore', 'pg_trgm']) { assert(plan.native_extension_lifecycle_sql_names.includes(sql)); assert( diff --git a/tools/packaging/cargo-package-test-closure.mts b/tools/packaging/cargo-package-test-closure.mts index e0dd509a9..67d1349b3 100644 --- a/tools/packaging/cargo-package-test-closure.mts +++ b/tools/packaging/cargo-package-test-closure.mts @@ -191,11 +191,15 @@ function pathDependencyPatches(manifests, scratch, packagedManifests, packagedNa .filter(({ name }) => name === dependency.name) .map(({ version }) => exactVersion(version, dependency.name)), ); - if (packagedVersions.size !== 1 || !packagedVersions.has(local.version)) { + if (packagedVersions.size !== 1) { throw error( `local patch ${dependency.name}@${local.version} does not match packaged requirement ${[...packagedVersions].join(', ') || 'none'}`, ); } + // Independently released SDKs keep their exact dependency pins. A local + // source hint is usable only for that version; otherwise Cargo fetches + // the declared published dependency instead of substituting workspace code. + if (!packagedVersions.has(local.version)) continue; const realSource = realpathSync(directory); const previousSource = sourceDirectories.get(dependency.name); if (previousSource !== undefined && previousSource !== realSource) { diff --git a/tools/packaging/cargo-package-test-closure.test.mts b/tools/packaging/cargo-package-test-closure.test.mts index 35e89923f..5f91c5b9a 100644 --- a/tools/packaging/cargo-package-test-closure.test.mts +++ b/tools/packaging/cargo-package-test-closure.test.mts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -106,6 +106,40 @@ test('rejects conflicting path-patch sources for the same package identity', (t) ); }); +test('package qualification retains exact published pins after a local dependency advances', (t) => { + const root = fixture(t, 'cargo-closure-independent'); + const cratePath = closureCrate(root); + const dependency = path.join(root, 'carrier'); + writePackage(dependency, 'carrier'); + const sourceManifest = path.join(root, 'Cargo.toml'); + writeFileSync(sourceManifest, '[dependencies]\ncarrier = { path = "carrier", version = "*" }\n'); + for (const version of ['0.1.0', '0.2.0']) { + const file = path.join(dependency, 'Cargo.toml'); + writeFileSync( + file, + readFileSync(file, 'utf8').replace(/^version = "[^"]+"/mu, `version = "${version}"`), + ); + const scratch = path.join(root, `work-${version}`); + const manifestFile = preparePackagedCargoTestClosure({ + cratePath, + scratch, + pathDependencyManifests: [sourceManifest], + }); + const manifest = Bun.TOML.parse(readFileSync(manifestFile, 'utf8')); + assert.equal(manifest.dependencies.carrier.version, '=0.1.0'); + const config = Bun.TOML.parse(readFileSync(path.join(scratch, '.cargo/config.toml'), 'utf8')); + if (version === '0.1.0') { + const patch = config.patch['crates-io'].carrier.path; + assert.equal( + Bun.TOML.parse(readFileSync(path.join(patch, 'Cargo.toml'), 'utf8')).package.version, + '0.1.0', + ); + } else { + assert.equal(config.patch, undefined); + } + } +}); + test('rejects unsafe packaged names', (t) => { const root = fixture(t, 'cargo-closure-unsafe'); const stage = path.join(root, 'stage'); diff --git a/tools/release/independent-version-pins.test.mts b/tools/release/independent-version-pins.test.mts index 83edd29a3..9cb8a92d6 100644 --- a/tools/release/independent-version-pins.test.mts +++ b/tools/release/independent-version-pins.test.mts @@ -9,13 +9,18 @@ import { compatibilityVersionValue, loadProducts, productCompatibilityVersion, + productDependencyCompatibilityVersion, ROOT, } from './release-graph.mts'; import { prepareSourceOnlyNpmPackage, SOURCE_ONLY_NPM_PROFILES, } from '../packaging/source-only-sdk-package.mts'; -import { packagedCargoManifestText } from '../packaging/cargo-source-package.mts'; +import { + packagedCargoManifestText, + packageGeneratedCargoSource, +} from '../packaging/cargo-source-package.mts'; +import { preparePackagedCargoTestClosure } from '../packaging/cargo-package-test-closure.mts'; import { renderReleaseCargoToml } from '../../src/native/sdks/rust/tools/prepare-rust-release-source.mts'; import { renderOliphauntWasixReleaseCargoToml } from '../../src/wasix/sdks/rust/tools/prepare-rust-release-source.mts'; import { prepareWasixTypescriptPackage } from '../../src/wasix/sdks/ts/tools/package.mts'; @@ -57,14 +62,22 @@ for (const [index, product] of [ const metadata = products[product]; const version = `${Number(metadata.version.split('.')[0]) + 10 + index}.0.0`; manifest[metadata.path] = version; - const file = metadata.version_files[0]; - if (path.basename(file) === 'package.json') { - write(file, JSON.stringify({ ...json(file), version })); - } else if (path.basename(file) === 'Cargo.toml') { - write(file, read(file).replace(/^version = "[^"]+"/mu, `version = "${version}"`)); - } else { - assert.equal(path.basename(file), 'VERSION'); - write(file, `${version}\n`); + for (const file of metadata.version_files) { + if (path.basename(file) === 'package.json') { + write(file, JSON.stringify({ ...json(file), version })); + } else if (path.basename(file) === 'Cargo.toml') { + write(file, read(file).replace(/^version = "[^"]+"/mu, `version = "${version}"`)); + } else if (path.basename(file) === 'gradle.properties') { + write(file, read(file).replace(/^VERSION_NAME=.+/mu, `VERSION_NAME=${version}`)); + } else if (path.basename(file) === 'liboliphaunt_native.c') { + write( + file, + read(file).replace(/(#define OLIPHAUNT_PRODUCT_VERSION )"[^"]+"/u, `$1"${version}"`), + ); + } else { + assert.equal(path.basename(file), 'VERSION'); + write(file, `${version}\n`); + } } } write('.release-please-manifest.json', JSON.stringify(manifest)); @@ -91,6 +104,30 @@ test('every SDK retains its declared compatibility pins after independent depend } }); +test('React Native resolves the runtime through its pinned Swift release after Swift advances', () => { + const swiftPin = productCompatibilityVersion('oliphaunt-react-native', 'oliphaunt-swift'); + const nativePin = sdkPins.find( + (entry) => entry.product === 'oliphaunt-swift' && entry.sourceProduct === 'liboliphaunt-native', + ).value; + assert.equal( + productDependencyCompatibilityVersion( + 'oliphaunt-react-native', + 'oliphaunt-swift', + 'liboliphaunt-native', + 'independent-version-pins', + { + readCompatibility(product, source, prefix, { ref }) { + assert.equal(product, 'oliphaunt-swift'); + assert.equal(source, 'liboliphaunt-native'); + assert.equal(ref, `${products[product].tag_prefix}${swiftPin}`); + return nativePin; + }, + }, + ), + nativePin, + ); +}); + test('native TypeScript and React Native npm staging retains independent product pins', () => { for (const [profile, source] of [ ['js', 'src/native/sdks/ts/package.json'], @@ -254,6 +291,41 @@ test('WASIX Rust source generation retains its runtime and explicit query pins', } }); +test('WASIX Rust package qualification does not patch older pins with newer workspace sources', () => { + const source = read('src/wasix/sdks/rust/Cargo.toml'); + const stage = path.join(ROOT, 'target', 'independent-version-pins', 'cargo-closure'); + mkdirSync(path.join(stage, 'src'), { recursive: true }); + writeFileSync(path.join(stage, 'src/lib.rs'), 'pub fn fixture() {}\n'); + writeFileSync(path.join(stage, 'Cargo.toml'), renderOliphauntWasixReleaseCargoToml(source)); + // A minimal payload exercises the real packaged dependency manifest. Cargo + // compilation is owned by each installed-consumer task. + const cratePath = packageGeneratedCargoSource( + path.join(stage, 'Cargo.toml'), + path.join(stage, 'crate'), + { + root: ROOT, + rel: String, + fail: (message) => { + throw new Error(message); + }, + }, + ); + const scratch = path.join(stage, 'consumer'); + const manifestFile = preparePackagedCargoTestClosure({ + cratePath, + scratch, + pathDependencyManifests: [path.join(ROOT, 'src/wasix/sdks/rust/Cargo.toml')], + }); + const packaged = Bun.TOML.parse(readFileSync(manifestFile, 'utf8')); + assert.equal( + packaged.dependencies['liboliphaunt-wasix-portable'].version, + `=${Bun.TOML.parse(source).package.metadata.oliphaunt['runtime-version']}`, + ); + const config = Bun.TOML.parse(readFileSync(path.join(scratch, '.cargo/config.toml'), 'utf8')); + assert.equal(config.patch?.['crates-io']?.['liboliphaunt-wasix-portable'], undefined); + assert.equal(config.patch?.['crates-io']?.['oliphaunt-query'], undefined); +}); + function mavenTests() { return spawnSync( process.execPath, diff --git a/tools/release/moon.yml b/tools/release/moon.yml index 1a96fa9ec..22e306281 100644 --- a/tools/release/moon.yml +++ b/tools/release/moon.yml @@ -190,6 +190,11 @@ tasks: - /moon.yml - /.release-please-manifest.json - /Cargo.toml + - /Cargo.lock + - /**/Cargo.toml + - /src/{native,wasix}/**/*.rs + - /src/query/rust/**/*.rs + - /src/database-resources/icu/cargo/**/*.rs - /Package.swift - /package.json - /release-please-config.json diff --git a/tools/release/normal-publication-plan.test.mts b/tools/release/normal-publication-plan.test.mts index 55ff6eb52..de344acb5 100644 --- a/tools/release/normal-publication-plan.test.mts +++ b/tools/release/normal-publication-plan.test.mts @@ -61,7 +61,7 @@ describe('normal publication plan', () => { ]; expect(buildPlan(graph, files).releaseProducts).toEqual([]); expect(buildPlan(graph, [...files, 'src/native/sdks/rust/src/lib.rs']).releaseProducts).toEqual( - ['oliphaunt-rust'], + ['oliphaunt-kotlin', 'oliphaunt-rust', 'oliphaunt-swift'], ); const explicit = { ...graph, @@ -217,13 +217,26 @@ describe('normal publication plan', () => { expect(runtime.topology.carrierCount).toBe(runtime.catalog.carriers.length); const contrib = realSelection('src/extensions/contrib/postgres18.toml'); - expect(contrib.release.directProducts).toEqual(['liboliphaunt-native', 'liboliphaunt-wasix']); - expect(contrib.release.releaseProducts).toEqual(['liboliphaunt-native', 'liboliphaunt-wasix']); - expect(contrib.release.releaseProducts).not.toContain('oliphaunt-wasix-napi'); + expect(contrib.release.directProducts).toEqual([ + 'liboliphaunt-native', + 'liboliphaunt-wasix', + 'oliphaunt-wasix-napi', + ]); + expect(contrib.release.releaseProducts).toEqual([ + 'liboliphaunt-native', + 'liboliphaunt-wasix', + 'oliphaunt-wasix-napi', + ]); const icu = realSelection('src/database-resources/icu/cargo/src/lib.rs'); - expect(icu.release.directProducts).toEqual(['database-resources']); - expect(icu.release.releaseProducts).toEqual(['database-resources']); + expect(icu.release.directProducts.sort()).toEqual([ + 'database-resources', + 'oliphaunt-wasix-napi', + ]); + expect(icu.release.releaseProducts.sort()).toEqual([ + 'database-resources', + 'oliphaunt-wasix-napi', + ]); const sdk = realSelection('src/native/sdks/react-native/CHANGELOG.md'); expect(sdk.release.directProducts).toEqual(['oliphaunt-react-native']); diff --git a/tools/release/prepare-release-candidate.test.mts b/tools/release/prepare-release-candidate.test.mts index d1d9ac7c1..54cd13433 100644 --- a/tools/release/prepare-release-candidate.test.mts +++ b/tools/release/prepare-release-candidate.test.mts @@ -1,7 +1,7 @@ import { test, expect } from 'bun:test'; import { Manifest } from 'release-please'; import { Version } from 'release-please/build/src/version.js'; -import { buildPlan, loadGraph } from './release-graph.mts'; +import { buildPlan, declaredSharedSourceImpacts, loadGraph, ROOT } from './release-graph.mts'; import { includeOwnedSourceCommits, useSourceDate, @@ -19,15 +19,33 @@ const first = 'a'.repeat(40); const second = 'b'.repeat(40); const head = 'c'.repeat(40); -test('addon releases include embedded Rust and runtime sources without releasing unrelated SDKs', () => { - for (const [file, owner] of [ - ['src/wasix/sdks/rust/src/oliphaunt/base.rs', 'oliphaunt-wasix-rust'], - ['src/wasix/pgwire-server/src/lib.rs', 'oliphaunt-pgwire-server'], - ['src/query/rust/src/lib.rs', 'oliphaunt-query'], - ['src/wasix/runtime/crates/assets/src/lib.rs', 'liboliphaunt-wasix'], +test('every binary release includes its compiled sources across product boundaries', () => { + const mobile = ['oliphaunt-swift', 'oliphaunt-kotlin']; + for (const [file, products] of [ + ['src/wasix/sdks/rust/src/oliphaunt/base.rs', ['oliphaunt-wasix-rust', 'oliphaunt-wasix-napi']], + ['src/wasix/pgwire-server/src/lib.rs', ['oliphaunt-pgwire-server', 'oliphaunt-wasix-napi']], + [ + 'src/query/rust/src/lib.rs', + ['oliphaunt-query', 'oliphaunt-broker', 'oliphaunt-wasix-napi', ...mobile], + ], + ['src/wasix/runtime/crates/assets/src/lib.rs', ['liboliphaunt-wasix', 'oliphaunt-wasix-napi']], + ['src/wasix/runtime/toolchain.toml', ['liboliphaunt-wasix', 'oliphaunt-wasix-napi']], + [ + 'src/wasix/postgres-tools/crates/tools/build-support.rs', + ['postgres-tools-wasix', 'oliphaunt-wasix-napi'], + ], + ['src/database-resources/icu/cargo/src/lib.rs', ['database-resources', 'oliphaunt-wasix-napi']], + [ + 'src/native/rust-bindings/src/lib.rs', + ['liboliphaunt-native-bindings', 'oliphaunt-broker', 'oliphaunt-node-direct', ...mobile], + ], + ['src/native/broker/src/lib.rs', ['oliphaunt-broker', ...mobile]], + ['src/native/sdks/rust/src/lib.rs', ['oliphaunt-rust', ...mobile]], + ['src/native/mobile-bindings/src/lib.rs', mobile], + ['src/native/sdks/rust/crates/oliphaunt-build/src/lib.rs', ['oliphaunt-rust', ...mobile]], ]) { const plan = buildPlan(graph, [file]); - expect(plan.releaseProducts.sort()).toEqual([owner, 'oliphaunt-wasix-napi'].sort()); + expect(plan.releaseProducts.sort()).toEqual(products.sort()); } for (const file of [ 'src/wasix/sdks/rust/tests/runtime_smoke.rs', @@ -35,6 +53,78 @@ test('addon releases include embedded Rust and runtime sources without releasing ]) { expect(buildPlan(graph, [file]).releaseProducts).not.toContain('oliphaunt-wasix-napi'); } + for (const file of [ + 'src/native/sdks/rust/tests/mobile_broker.rs', + 'src/native/broker/README.md', + ]) { + for (const product of mobile) + expect(buildPlan(graph, [file]).releaseProducts).not.toContain(product); + } + // A dynamically loaded native runtime does not become part of the adapter binary. + expect(buildPlan(graph, ['src/native/runtime/VERSION']).releaseProducts).toEqual([ + 'liboliphaunt-native', + ]); + for (const file of ['Cargo.toml', 'Cargo.lock']) { + expect(buildPlan(graph, [file]).releaseProducts.sort()).toEqual([ + 'oliphaunt-broker', + 'oliphaunt-kotlin', + 'oliphaunt-node-direct', + 'oliphaunt-swift', + 'oliphaunt-wasix-napi', + ]); + } +}); + +test('new transitive, build and target Cargo dependencies automatically select embedding releases', () => { + const scratch = mkdtempSync(path.join(ROOT, 'target/embedded-cargo-')); + const relative = path.relative(ROOT, scratch).split(path.sep).join('/'); + try { + for (const name of ['binary', 'middle', 'leaf', 'build', 'target', 'test-only']) { + mkdirSync(path.join(scratch, name, 'src'), { recursive: true }); + writeFileSync(path.join(scratch, name, 'src/lib.rs'), 'pub fn fixture() {}\n'); + writeFileSync( + path.join(scratch, name, 'Cargo.toml'), + `[package]\nname = "${name}"\nversion = "1.0.0"\n`, + ); + } + writeFileSync( + path.join(scratch, 'binary/Cargo.toml'), + '[dependencies]\nmiddle = { path = "../middle" }\n' + + '[build-dependencies]\nbuild = { path = "../build" }\n' + + '[target.\'cfg(unix)\'.dependencies]\ntarget = { path = "../target", optional = true }\n' + + '[dev-dependencies]\ntest = { path = "../test-only" }\n', + ); + writeFileSync( + path.join(scratch, 'middle/Cargo.toml'), + '[dependencies]\nleaf = { path = "../leaf" }\n', + ); + const impacts = declaredSharedSourceImpacts({ + 'oliphaunt-node-direct': { + embedded_cargo_manifests: [`${relative}/binary/Cargo.toml`], + }, + }); + const fixtureGraph = { ...graph, shared_release_sources: impacts }; + for (const name of ['middle', 'leaf', 'build', 'target']) { + expect(buildPlan(fixtureGraph, [`${relative}/${name}/src/lib.rs`]).releaseProducts).toEqual([ + 'oliphaunt-node-direct', + ]); + } + expect(buildPlan(fixtureGraph, [`${relative}/test-only/src/lib.rs`]).releaseProducts).toEqual( + [], + ); + expect(buildPlan(fixtureGraph, [`${relative}/leaf/README.md`]).releaseProducts).toEqual([]); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +}); + +test('embedded generated payloads follow private producer ownership for source-pin changes', () => { + for (const file of ['src/third-party/postgres/source.toml', 'src/third-party/icu/source.toml']) { + expect(existsSync(path.join(ROOT, file))).toBe(true); + const plan = buildPlan(graph, [file]); + expect(plan.releaseProducts).toContain('liboliphaunt-wasix'); + expect(plan.releaseProducts).toContain('oliphaunt-wasix-napi'); + } }); async function generate(commits, baselines = { [native]: first, [wasix]: first }) { diff --git a/tools/release/product-version.mts b/tools/release/product-version.mts index 207ec68d9..66c4a4dfb 100644 --- a/tools/release/product-version.mts +++ b/tools/release/product-version.mts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { currentProductVersion } from './release-artifact-targets.mts'; +import { productDependencyCompatibilityVersion } from './release-graph.mts'; const TOOL = 'product-version.mts'; @@ -9,7 +10,9 @@ function fail(message) { } function usage() { - fail('usage: tools/release/product-version.mts version '); + fail( + 'usage: tools/release/product-version.mts version | dependency-compatibility ', + ); } function ensureSemver(product, version) { @@ -27,6 +30,10 @@ export async function currentVersion(product) { } async function main(argv) { + if (argv.length === 4 && argv[0] === 'dependency-compatibility') { + console.log(productDependencyCompatibilityVersion(argv[1], argv[2], argv[3], TOOL)); + return; + } if (argv.length !== 2 || argv[0] !== 'version') { usage(); } diff --git a/tools/release/release-graph.mts b/tools/release/release-graph.mts index aa4b28d5b..70700a7af 100644 --- a/tools/release/release-graph.mts +++ b/tools/release/release-graph.mts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { CONTRIB_CARRIERS_PATH, loadContribCarriers, @@ -373,16 +374,86 @@ function contribCarrierImpact(products, prefix) { }; } -function declaredSharedSourceImpacts(products, prefix) { +export function declaredSharedSourceImpacts(products, prefix = 'release-graph') { return Object.entries(products).flatMap(([product, config]) => { - const paths = config.shared_source_paths ?? []; - assertStringList(paths, `${product}.shared_source_paths`, prefix); - return paths.map((sourcePath) => { + const manifests = config.embedded_cargo_manifests ?? []; + assertStringList(manifests, `${product}.embedded_cargo_manifests`, prefix); + const declaredPaths = config.shared_source_paths ?? []; + assertStringList(declaredPaths, `${product}.shared_source_paths`, prefix); + const paths = [...declaredPaths]; + // These binaries use the workspace's locked dependency graph and build + // configuration, which can change shipped code without a local source edit. + if (manifests.length > 0) paths.push('Cargo.toml', 'Cargo.lock'); + const visited = new Set(); + const visit = (manifestPath) => { + if (visited.has(manifestPath)) return; + visited.add(manifestPath); + const directory = path.posix.dirname(manifestPath); + const manifest = readToml(manifestPath, prefix); + // Local runtime and build dependencies become part of the shipped binary. + // Derive their sources from Cargo so a new dependency cannot silently + // cross an independently versioned release boundary. Tests stay local. + paths.push(manifestPath); + for (const entry of readdirSync(path.join(ROOT, directory), { + withFileTypes: true, + })) { + if ( + (entry.isDirectory() && entry.name === 'src') || + (entry.isFile() && entry.name.endsWith('.rs')) + ) { + paths.push(path.posix.join(directory, entry.name)); + } + } + for (const section of [manifest, ...Object.values(manifest.target ?? {})]) { + for (const table of ['dependencies', 'build-dependencies']) { + for (const dependency of Object.values(section[table] ?? {})) { + if (dependency?.workspace === true) { + fail( + prefix, + `${product} embedded Cargo dependencies must declare their source paths explicitly`, + ); + } + if (typeof dependency?.path === 'string') { + if (path.posix.isAbsolute(dependency.path)) { + fail(prefix, `${product} embedded Cargo dependency must stay in the repository`); + } + const dependencyManifest = path.posix.normalize( + path.posix.join(directory, dependency.path, 'Cargo.toml'), + ); + if ( + dependencyManifest.startsWith('../') || + path.posix.isAbsolute(dependencyManifest) + ) { + fail(prefix, `${product} embedded Cargo dependency must stay in the repository`); + } + visit(dependencyManifest); + } + } + } + } + }; + for (const manifest of manifests) { + if ( + path.posix.isAbsolute(manifest) || + manifest.split('/').includes('..') || + path.posix.basename(manifest) !== 'Cargo.toml' + ) { + fail( + prefix, + `${product}.embedded_cargo_manifests must contain repository-relative Cargo.toml paths`, + ); + } + visit(manifest); + } + return [...new Set(paths)].map((sourcePath) => { if (!sourcePath || path.isAbsolute(sourcePath) || sourcePath.startsWith('../')) { fail(prefix, `${product}.shared_source_paths must contain repository-relative paths`); } requireExistingPath(sourcePath, `${product} shared source`, prefix); - return { source_paths: [sourcePath.replace(/\/$/u, '')], products: [product] }; + return { + source_paths: [sourcePath.replace(/\/$/u, '')], + products: [product], + }; }); }); } @@ -391,6 +462,18 @@ export function loadGraph(prefix = 'release-graph') { const moonProjects = moonProjectsById(prefix); releasePackagePaths(moonProjects, prefix); const products = loadProducts(prefix); + for (const [product, config] of Object.entries(products)) { + assertStringList( + config.embedded_payload_products ?? [], + `${product}.embedded_payload_products`, + prefix, + ); + for (const dependency of config.embedded_payload_products ?? []) { + if (!(dependency in products) || dependency === product) { + fail(prefix, `${product}.embedded_payload_products must name other release products`); + } + } + } const graph = { products, moon_projects: Object.fromEntries(moonProjects), @@ -567,7 +650,12 @@ export function compatibilityVersionValue( return value; } -export function productCompatibilityVersion(product, sourceProduct, prefix = 'release-graph') { +export function productCompatibilityVersion( + product, + sourceProduct, + prefix = 'release-graph', + options = {}, +) { const entries = compatibilityVersionEntries(loadProducts(prefix), { requireSourceProduct: true, prefix, @@ -575,13 +663,63 @@ export function productCompatibilityVersion(product, sourceProduct, prefix = 're if (entries.length === 0) { fail(prefix, `${product} does not declare compatibility with ${sourceProduct}`); } - const values = new Set(entries.map((entry) => compatibilityVersionValue(entry, { prefix }))); + const values = new Set( + entries.map((entry) => compatibilityVersionValue(entry, { prefix, ...options })), + ); if (values.size !== 1) { fail(prefix, `${product} declares conflicting compatibility versions for ${sourceProduct}`); } return [...values][0]; } +// Resolve a transitive pin through the dependency version the consumer actually +// declares. A newer workspace SDK must not rewrite an older consumer's runtime. +export function productDependencyCompatibilityVersion( + product, + dependencyProduct, + sourceProduct, + prefix = 'release-graph', + { readCompatibility = productCompatibilityVersion } = {}, +) { + const version = productCompatibilityVersion(product, dependencyProduct, prefix); + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(version)) { + fail(prefix, `${product} must pin an exact stable ${dependencyProduct} version`); + } + const dependency = loadProducts(prefix)[dependencyProduct]; + const ref = version === dependency.version ? null : dependency.tag_prefix + version; + if ( + ref && + !process.env.OLIPHAUNT_PRODUCT_HISTORY && + readCompatibility === productCompatibilityVersion + ) { + const result = spawnSync( + 'bash', + [ + path.join(ROOT, 'tools/release/with-product-history.sh'), + ROOT, + 'HEAD', + '', + '@workspace', + process.execPath, + path.join(ROOT, 'tools/release/product-version.mts'), + 'dependency-compatibility', + product, + dependencyProduct, + sourceProduct, + ], + { cwd: ROOT, encoding: 'utf8', timeout: 120_000 }, + ); + if (result.status !== 0) + throw new Error(`${prefix}: could not resolve ${ref}: ${result.stderr}`); + const resolved = result.stdout.trim(); + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(resolved)) { + fail(prefix, `${ref} returned an invalid ${sourceProduct} compatibility version`); + } + return resolved; + } + return readCompatibility(dependencyProduct, sourceProduct, prefix, { ref }); +} + export function tagPrefixes(config, prefix = 'release-graph') { if (typeof config.tag_prefix !== 'string' || config.tag_prefix.length === 0) { fail(prefix, 'release products must declare tag_prefix'); @@ -1288,6 +1426,25 @@ export function buildPlan(graph, files, prefix = 'release-graph') { directProjects, prefix, ); + // Generated payload bytes cross the same release boundary as compiled source. + // Resolve producer ownership first so source pins, recipes and private build + // projects select every product that embeds their resulting artifacts. + let expanded; + do { + expanded = false; + for (const [product, config] of Object.entries(products)) { + if ( + !releaseProductSet.has(product) && + (config.embedded_payload_products ?? []).some((dependency) => + releaseProductSet.has(dependency), + ) + ) { + releaseProductSet.add(product); + directProjects.add(releaseProductProjectId(product, products, projects, prefix)); + expanded = true; + } + } + } while (expanded); const releaseProducts = releaseOrder(products, projects, releaseProductSet, prefix); const direct = releaseOrder( products, From 0d243750acf7dbc4c94d32274ce8e1a10559d2db Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 5 Oct 2026 02:17:03 +0000 Subject: [PATCH 3/6] test(release): make dependency identity fixtures work in clean checkouts --- src/native/sdks/swift/tools/ios-carrier-manifest.test.mts | 1 + tools/release/independent-version-pins.test.sh | 2 +- tools/release/moon.yml | 2 ++ tools/release/prepare-release-candidate.test.mts | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts index 9ce665383..e9b116abd 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts @@ -136,6 +136,7 @@ function writeManifest(root, product, body) { } test('an independently pinned base carrier uses the pinned release and rejects current runtime assets', async () => { + mkdirSync(path.join(ROOT, 'target'), { recursive: true }); const root = mkdtempSync(path.join(ROOT, 'target', 'ios-pinned-carrier-test-')); const version = '0.1.0'; try { diff --git a/tools/release/independent-version-pins.test.sh b/tools/release/independent-version-pins.test.sh index 91a997d55..0b5ce0fb4 100644 --- a/tools/release/independent-version-pins.test.sh +++ b/tools/release/independent-version-pins.test.sh @@ -6,7 +6,7 @@ trap 'rm -rf "$scratch"' EXIT # Exercise the current sources in an isolated tree, including uncommitted fixes. git ls-files -z --cached --others --exclude-standard -- tools src \ - LICENSE THIRD_PARTY_NOTICES.md .prototools package.json \ + LICENSE THIRD_PARTY_NOTICES.md .prototools package.json Cargo.toml Cargo.lock \ release-please-config.json .release-please-manifest.json \ > "$scratch/files" tar -cf "$scratch/source.tar" --null -T "$scratch/files" diff --git a/tools/release/moon.yml b/tools/release/moon.yml index 22e306281..ff3d94514 100644 --- a/tools/release/moon.yml +++ b/tools/release/moon.yml @@ -156,6 +156,8 @@ tasks: - unit command: bash tools/release/independent-version-pins.test.sh inputs: + - /Cargo.toml + - /Cargo.lock - "@group(package-test-metadata)" - "@group(release-target-contract)" - "@group(legal-files)" diff --git a/tools/release/prepare-release-candidate.test.mts b/tools/release/prepare-release-candidate.test.mts index 54cd13433..bfc6a31f4 100644 --- a/tools/release/prepare-release-candidate.test.mts +++ b/tools/release/prepare-release-candidate.test.mts @@ -76,6 +76,7 @@ test('every binary release includes its compiled sources across product boundari }); test('new transitive, build and target Cargo dependencies automatically select embedding releases', () => { + mkdirSync(path.join(ROOT, 'target'), { recursive: true }); const scratch = mkdtempSync(path.join(ROOT, 'target/embedded-cargo-')); const relative = path.relative(ROOT, scratch).split(path.sep).join('/'); try { From e59d182b4aad02970b1e557cf8e4b9b1c066d7e0 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 5 Oct 2026 02:46:32 +0000 Subject: [PATCH 4/6] fix(release): derive compiled ownership from Cargo metadata --- .github/moon.yml | 7 +- .github/workflows/ci.yml | 10 ++ .github/workflows/release.yml | 11 ++- .moon/tasks/cargo.yml | 4 +- src/docs/maintainers/release.md | 26 +++++- .../release/independent-version-pins.test.mts | 14 +-- tools/release/moon.yml | 6 +- .../prepare-release-candidate.test.mts | 54 +++++++++-- tools/release/release-graph.mts | 91 ++++++++++++------- 9 files changed, 159 insertions(+), 64 deletions(-) diff --git a/.github/moon.yml b/.github/moon.yml index 932c55d33..8af6767ca 100644 --- a/.github/moon.yml +++ b/.github/moon.yml @@ -35,11 +35,16 @@ tasks: cache: true runFromWorkspaceRoot: true check: - tags: ["policy", "assertion", "quality", "static", "requires-maintainer-tools"] + tags: ["policy", "assertion", "quality", "static", "requires-maintainer-tools", "requires-rust"] command: "bash tools/ci/check-workflows.sh" inputs: - "/.moon/toolchains.yml" + - "/.moon/tasks/**/*" + - "/**/moon.yml" - "/.prototools" + - "/rust-toolchain.toml" + - "/Cargo.lock" + - "/**/Cargo.toml" - "/.github/actions/**/*" - "/.github/scripts/**/*" - "/.github/workflows/**/*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbd2ed285..bbbf4714c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,11 @@ jobs: restore-keys: | ios-carrier-v1-${{ hashFiles('src/native/runtime/VERSION', 'src/native/sdks/swift/tools/ios-carrier-manifest.mts', 'tools/packaging/**', 'tools/release/**') }}- + - name: Set up Cargo for dependency metadata + uses: ./.github/actions/setup-rust + with: + cache: "false" + - name: Plan artifact builder jobs id: plan env: @@ -242,6 +247,11 @@ jobs: task-cache: "false" install-workspace: "false" + - name: Set up Cargo for dependency metadata + uses: ./.github/actions/setup-rust + with: + cache: "false" + - name: Check release intent env: PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bce48c989..8b6a8acbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,14 +113,15 @@ jobs: with: task-cache: "false" install-workspace: "true" + - name: Set up Rust for dependency metadata and manifest synchronization + uses: ./.github/actions/setup-rust + with: + cache: "false" - name: Generate the Release Please candidate locally id: prepare_candidate env: GH_TOKEN: ${{ steps.release_pr_token.outputs.token }} run: bash tools/release/prepare-release-pr.sh "$RUNNER_TEMP/release-candidate" - - name: Set up Rust for release manifest synchronization - if: ${{ steps.prepare_candidate.outputs.required == 'true' }} - uses: ./.github/actions/setup-rust - name: Close and validate the local release candidate if: ${{ steps.prepare_candidate.outputs.required == 'true' }} run: bash tools/release/close-release-candidate.sh "$RUNNER_TEMP/release-candidate" @@ -160,6 +161,10 @@ jobs: with: task-cache: "false" install-workspace: "true" + - name: Set up Cargo for dependency metadata + uses: ./.github/actions/setup-rust + with: + cache: "false" - name: Plan product releases id: release_plan run: | diff --git a/.moon/tasks/cargo.yml b/.moon/tasks/cargo.yml index ce510714d..cb3a4b84c 100644 --- a/.moon/tasks/cargo.yml +++ b/.moon/tasks/cargo.yml @@ -6,7 +6,9 @@ inheritedBy: fileGroups: cargo-sources: - "src/**/*" - - "{Cargo.toml,build.rs}" + - "**/*.rs" + - "!**/{tests,benches,examples}/**" + - "Cargo.toml" tasks: # Cargo owns compilation. This command-free node only carries source hashes diff --git a/src/docs/maintainers/release.md b/src/docs/maintainers/release.md index b205c92fb..e3c48493f 100644 --- a/src/docs/maintainers/release.md +++ b/src/docs/maintainers/release.md @@ -156,9 +156,14 @@ physically bundled into both products. No Moon dependency edge creates another release candidate. Binary products declare `embedded_cargo_manifests` in their `release.toml`. -Release planning follows those manifests' local runtime, build, and target -dependencies through independent product boundaries and includes their Rust -sources and build scripts. It excludes development dependencies and prose. +Release planning reads Cargo's versioned `metadata --no-deps --frozen` output +and follows local runtime, build, and target dependencies through independent +product boundaries. Cargo resolves workspace inheritance, renamed dependencies, +and custom library, binary, and build-script source paths. All optional and +target dependencies participate, so adding a platform does not require another +release-planner branch. Development dependencies stay local. This read requires +the pinned Cargo toolchain but never compiles, fetches registries, or changes a +lockfile; source planning and release preparation declare that capability. The workspace Cargo manifest and lockfile conservatively select these binaries, since dependency resolution and build settings also determine their shipped bytes. This covers both Node addons, the native broker, and Swift/Kotlin native @@ -166,6 +171,21 @@ bindings. Generated assets use `embedded_payload_products`: changes to an embedded producer's sources, pins, or recipes also select the embedding binary. Source-only facades and dynamically loaded runtimes retain independent releases. +For a new compiled SDK, declare its shipped Cargo root once. Dependencies then +follow the Cargo manifests, including new targets. Declare generated payload +producers separately because Cargo cannot infer external artifact generation. +Non-Cargo shared inputs belong in `shared_source_paths`. New source-only SDKs +need compatibility pins and package qualification without a compiled-source +root. The shared version-drift gate discovers producer products from every SDK's +compatibility declarations, so a new dependency enters the fixture automatically. +Every SDK must test its packager with an independently advanced producer; release +packaging preserves the consumer's exact pin, while a source fixture that uses +newer workspace bytes must identify those bytes and remain unpublishable. +Moon's shared Cargo source group includes Rust files throughout each project, +including custom source directories and build helpers. Inputs outside the Cargo +project, such as resources read by a build script, still need explicit Moon +inputs and release ownership. + Moon `production` and `peer` edges describe source and qualification impact. Product-local `compatibility_versions` describe the exact published product versions a carrier consumes. A native runtime can therefore be published diff --git a/tools/release/independent-version-pins.test.mts b/tools/release/independent-version-pins.test.mts index 9cb8a92d6..12fe40b26 100644 --- a/tools/release/independent-version-pins.test.mts +++ b/tools/release/independent-version-pins.test.mts @@ -48,17 +48,9 @@ const manifest = json('.release-please-manifest.json'); // Advance dependencies without selecting their consumers. Distinct versions // expose both accidental coupling and substitution of a current workspace pin. -for (const [index, product] of [ - 'liboliphaunt-native', - 'liboliphaunt-wasix', - 'oliphaunt-broker', - 'oliphaunt-node-direct', - 'oliphaunt-wasix-napi', - 'oliphaunt-query', - 'oliphaunt-query-ts', - 'oliphaunt-swift', - 'oliphaunt-kotlin', -].entries()) { +for (const [index, product] of [...new Set(sdkPins.map((entry) => entry.sourceProduct))] + .sort() + .entries()) { const metadata = products[product]; const version = `${Number(metadata.version.split('.')[0]) + 10 + index}.0.0`; manifest[metadata.path] = version; diff --git a/tools/release/moon.yml b/tools/release/moon.yml index ff3d94514..9eccafe00 100644 --- a/tools/release/moon.yml +++ b/tools/release/moon.yml @@ -183,6 +183,7 @@ tasks: tags: - quality - unit + - requires-rust inputs: - /.moon/tasks/**/* - /.moon/toolchains.yml @@ -193,10 +194,9 @@ tasks: - /.release-please-manifest.json - /Cargo.toml - /Cargo.lock + - /rust-toolchain.toml - /**/Cargo.toml - - /src/{native,wasix}/**/*.rs - - /src/query/rust/**/*.rs - - /src/database-resources/icu/cargo/**/*.rs + - /src/**/*.rs - /Package.swift - /package.json - /release-please-config.json diff --git a/tools/release/prepare-release-candidate.test.mts b/tools/release/prepare-release-candidate.test.mts index bfc6a31f4..1f4b80459 100644 --- a/tools/release/prepare-release-candidate.test.mts +++ b/tools/release/prepare-release-candidate.test.mts @@ -7,7 +7,15 @@ import { useSourceDate, applyCandidate, } from './prepare-release-candidate.mts'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -75,12 +83,19 @@ test('every binary release includes its compiled sources across product boundari } }); -test('new transitive, build and target Cargo dependencies automatically select embedding releases', () => { +test('Cargo owns transitive, inherited, renamed, build, target and custom source inputs', () => { mkdirSync(path.join(ROOT, 'target'), { recursive: true }); const scratch = mkdtempSync(path.join(ROOT, 'target/embedded-cargo-')); const relative = path.relative(ROOT, scratch).split(path.sep).join('/'); + const previousCargoHome = process.env.CARGO_HOME; try { - for (const name of ['binary', 'middle', 'leaf', 'build', 'target', 'test-only']) { + const names = ['binary', 'middle', 'leaf', 'build', 'target', 'test-only']; + writeFileSync( + path.join(scratch, 'Cargo.toml'), + `[workspace]\nmembers = ${JSON.stringify(names)}\nresolver = "3"\n` + + '[workspace.dependencies]\nrenamed = { package = "leaf", path = "leaf" }\n', + ); + for (const name of names) { mkdirSync(path.join(scratch, name, 'src'), { recursive: true }); writeFileSync(path.join(scratch, name, 'src/lib.rs'), 'pub fn fixture() {}\n'); writeFileSync( @@ -88,24 +103,35 @@ test('new transitive, build and target Cargo dependencies automatically select e `[package]\nname = "${name}"\nversion = "1.0.0"\n`, ); } - writeFileSync( + appendFileSync( path.join(scratch, 'binary/Cargo.toml'), '[dependencies]\nmiddle = { path = "../middle" }\n' + '[build-dependencies]\nbuild = { path = "../build" }\n' + - '[target.\'cfg(unix)\'.dependencies]\ntarget = { path = "../target", optional = true }\n' + - '[dev-dependencies]\ntest = { path = "../test-only" }\n', + '[target.\'cfg(target_arch = "riscv64")\'.dependencies]\ntarget = { path = "../target", optional = true }\n' + + '[dev-dependencies]\ntest-only = { path = "../test-only" }\n', ); - writeFileSync( + appendFileSync( path.join(scratch, 'middle/Cargo.toml'), - '[dependencies]\nleaf = { path = "../leaf" }\n', + '[dependencies]\nrenamed.workspace = true\nserde = "1"\n', ); + mkdirSync(path.join(scratch, 'leaf/custom'), { recursive: true }); + mkdirSync(path.join(scratch, 'leaf/scripts'), { recursive: true }); + writeFileSync(path.join(scratch, 'leaf/custom/lib.rs'), 'pub fn custom() {}\n'); + writeFileSync(path.join(scratch, 'leaf/scripts/build.rs'), 'fn main() {}\n'); + writeFileSync( + path.join(scratch, 'leaf/Cargo.toml'), + '[package]\nname = "leaf"\nversion = "1.0.0"\nbuild = "scripts/build.rs"\n' + + '[lib]\npath = "custom/lib.rs"\n', + ); + // Planning must work before any registry cache or lockfile exists. + process.env.CARGO_HOME = path.join(scratch, 'empty-cargo-cache'); const impacts = declaredSharedSourceImpacts({ 'oliphaunt-node-direct': { embedded_cargo_manifests: [`${relative}/binary/Cargo.toml`], }, }); const fixtureGraph = { ...graph, shared_release_sources: impacts }; - for (const name of ['middle', 'leaf', 'build', 'target']) { + for (const name of ['middle', 'build', 'target']) { expect(buildPlan(fixtureGraph, [`${relative}/${name}/src/lib.rs`]).releaseProducts).toEqual([ 'oliphaunt-node-direct', ]); @@ -114,7 +140,17 @@ test('new transitive, build and target Cargo dependencies automatically select e [], ); expect(buildPlan(fixtureGraph, [`${relative}/leaf/README.md`]).releaseProducts).toEqual([]); + expect(buildPlan(fixtureGraph, [`${relative}/leaf/src/lib.rs`]).releaseProducts).toEqual([]); + for (const file of ['leaf/custom/module.rs', 'leaf/scripts/helper.rs']) { + expect(buildPlan(fixtureGraph, [`${relative}/${file}`]).releaseProducts).toEqual([ + 'oliphaunt-node-direct', + ]); + } + expect(existsSync(path.join(scratch, 'Cargo.lock'))).toBe(false); + expect(existsSync(path.join(process.env.CARGO_HOME, 'registry'))).toBe(false); } finally { + if (previousCargoHome === undefined) delete process.env.CARGO_HOME; + else process.env.CARGO_HOME = previousCargoHome; rmSync(scratch, { recursive: true, force: true }); } }); diff --git a/tools/release/release-graph.mts b/tools/release/release-graph.mts index 70700a7af..9cabfaa15 100644 --- a/tools/release/release-graph.mts +++ b/tools/release/release-graph.mts @@ -375,60 +375,85 @@ function contribCarrierImpact(products, prefix) { } export function declaredSharedSourceImpacts(products, prefix = 'release-graph') { + const packages = new Map(); + const relative = (file) => { + const value = path.relative(ROOT, file).split(path.sep).join('/'); + if (!value || value.startsWith('../') || path.isAbsolute(value)) { + fail(prefix, `embedded Cargo inputs must stay in the repository: ${file}`); + } + return value; + }; + const cargoPackage = (manifestPath) => { + if (!packages.has(manifestPath)) { + // Cargo resolves inherited/renamed dependencies and custom targets. No + // registry resolution, compilation, network or lockfile mutation belongs + // in release planning. Cache all workspace members from this one read. + const result = spawnSync( + 'cargo', + [ + 'metadata', + '--manifest-path', + manifestPath, + '--format-version', + '1', + '--no-deps', + '--frozen', + ], + { cwd: ROOT, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }, + ); + if (result.error || result.status !== 0) { + fail( + prefix, + `Cargo metadata failed for ${manifestPath}: ${result.error?.message ?? result.stderr}`, + ); + } + const metadata = JSON.parse(result.stdout); + for (const pkg of metadata.packages) { + packages.set(relative(pkg.manifest_path), { ...pkg, workspace: metadata.workspace_root }); + } + } + const pkg = packages.get(manifestPath); + if (!pkg) fail(prefix, `Cargo metadata omitted embedded package ${manifestPath}`); + return pkg; + }; return Object.entries(products).flatMap(([product, config]) => { const manifests = config.embedded_cargo_manifests ?? []; assertStringList(manifests, `${product}.embedded_cargo_manifests`, prefix); const declaredPaths = config.shared_source_paths ?? []; assertStringList(declaredPaths, `${product}.shared_source_paths`, prefix); const paths = [...declaredPaths]; - // These binaries use the workspace's locked dependency graph and build - // configuration, which can change shipped code without a local source edit. - if (manifests.length > 0) paths.push('Cargo.toml', 'Cargo.lock'); const visited = new Set(); const visit = (manifestPath) => { if (visited.has(manifestPath)) return; visited.add(manifestPath); const directory = path.posix.dirname(manifestPath); - const manifest = readToml(manifestPath, prefix); + const pkg = cargoPackage(manifestPath); // Local runtime and build dependencies become part of the shipped binary. // Derive their sources from Cargo so a new dependency cannot silently // cross an independently versioned release boundary. Tests stay local. paths.push(manifestPath); + // Locked dependencies and workspace build settings determine binary bytes. + for (const name of ['Cargo.toml', 'Cargo.lock']) { + const file = path.join(pkg.workspace, name); + if (existsSync(file)) paths.push(relative(file)); + } for (const entry of readdirSync(path.join(ROOT, directory), { withFileTypes: true, })) { - if ( - (entry.isDirectory() && entry.name === 'src') || - (entry.isFile() && entry.name.endsWith('.rs')) - ) { + if (entry.isFile() && entry.name.endsWith('.rs')) { paths.push(path.posix.join(directory, entry.name)); } } - for (const section of [manifest, ...Object.values(manifest.target ?? {})]) { - for (const table of ['dependencies', 'build-dependencies']) { - for (const dependency of Object.values(section[table] ?? {})) { - if (dependency?.workspace === true) { - fail( - prefix, - `${product} embedded Cargo dependencies must declare their source paths explicitly`, - ); - } - if (typeof dependency?.path === 'string') { - if (path.posix.isAbsolute(dependency.path)) { - fail(prefix, `${product} embedded Cargo dependency must stay in the repository`); - } - const dependencyManifest = path.posix.normalize( - path.posix.join(directory, dependency.path, 'Cargo.toml'), - ); - if ( - dependencyManifest.startsWith('../') || - path.posix.isAbsolute(dependencyManifest) - ) { - fail(prefix, `${product} embedded Cargo dependency must stay in the repository`); - } - visit(dependencyManifest); - } - } + for (const target of pkg.targets) { + if (target.kind.some((kind) => ['test', 'bench', 'example'].includes(kind))) continue; + const source = relative(target.src_path); + // Custom lib/bin/build-script directories can contain sibling modules. + const sourceDirectory = path.posix.dirname(source); + paths.push(sourceDirectory === directory ? source : sourceDirectory); + } + for (const dependency of pkg.dependencies) { + if (dependency.kind !== 'dev' && typeof dependency.path === 'string') { + visit(relative(path.join(dependency.path, 'Cargo.toml'))); } } }; From 45bb99e9d4b6bb0db572f6a2ff7e2cb6b9468c23 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 5 Oct 2026 03:07:50 +0000 Subject: [PATCH 5/6] fix(ci): align pinned carrier qualification with shared tooling --- .github/moon.yml | 3 +++ .github/workflows/ci.yml | 2 ++ src/native/sdks/react-native/moon.yml | 2 ++ src/native/sdks/swift/moon.yml | 2 ++ .../sdks/swift/tools/ios-carrier-manifest.mts | 3 ++- .../sdks/swift/tools/pinned-native-carrier.mts | 8 ++++++-- .../sdks/swift/tools/pinned-native-carrier.test.mts | 13 +++++++++++-- tools/ci/ci-plan.sh | 3 ++- tools/ci/ci-release-scope.test.sh | 8 +++++--- 9 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/moon.yml b/.github/moon.yml index 8af6767ca..b1d3d8172 100644 --- a/.github/moon.yml +++ b/.github/moon.yml @@ -74,6 +74,9 @@ tasks: - "/tools/ci/affected.mts" - "/tools/ci/ci_plan.mts" - "/tools/ci/ci-plan.sh" + - "@group(package-test-metadata)" + - "/tools/release/product-version.mts" + - "/tools/release/with-product-history.sh" - "/src/native/sdks/ts/tools/published-consumer.mts" - "/src/native/sdks/ts/package.json" - "/src/native/sdks/swift/tools/ios-carrier-manifest.mts" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbbf4714c..6f793ecae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1543,6 +1543,7 @@ jobs: - name: Build Swift SDK package artifacts env: + GH_TOKEN: ${{ github.token }} OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR: ${{ github.workspace }}/target/liboliphaunt/abi-compatible-release-assets/ios-datum64 OLIPHAUNT_MOON_TRANSFERRED_DEPS_JSON: '["liboliphaunt-native:finalize-runtime-ios-abi", "oliphaunt-swift:package-bindings"]' run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' .github/scripts/run-planned-moon-job.sh swift-sdk-package @@ -1669,6 +1670,7 @@ jobs: - name: Build React Native SDK package artifacts env: + GH_TOKEN: ${{ github.token }} OLIPHAUNT_REACT_NATIVE_IOS_BASE_CARRIER: ${{ needs.affected.outputs.reuse_ios_carrier == 'true' && 'target/ci/ios-carrier/manifest.json' || '' }} OLIPHAUNT_REACT_NATIVE_IOS_RELEASE_ASSET_DIR: ${{ github.workspace }}/target/liboliphaunt/abi-compatible-release-assets/ios-datum64 OLIPHAUNT_MOON_TRANSFERRED_DEPS_JSON: '["liboliphaunt-native:finalize-runtime-ios-abi"]' diff --git a/src/native/sdks/react-native/moon.yml b/src/native/sdks/react-native/moon.yml index 165814cf5..ba6c74b09 100644 --- a/src/native/sdks/react-native/moon.yml +++ b/src/native/sdks/react-native/moon.yml @@ -122,6 +122,8 @@ tasks: - /src/extensions/artifacts/packages/tools/contrib-carriers.mts - /src/native/sdks/swift/tools/ios-carrier-manifest.mts - /src/native/sdks/swift/tools/pinned-native-carrier.mts + - /tools/release/github-read.mts + - /tools/release/github-core-request-journal.mts - /src/native/sdks/swift/tools/swift-carrier-resolver.mts - /src/native/sdks/swift/tools/render_swiftpm_release_package.mts - /src/native/sdks/swift/tools/swift-source-carrier-contract.mts diff --git a/src/native/sdks/swift/moon.yml b/src/native/sdks/swift/moon.yml index 632ffcb7e..372761ec5 100644 --- a/src/native/sdks/swift/moon.yml +++ b/src/native/sdks/swift/moon.yml @@ -198,6 +198,8 @@ tasks: - "/src/extensions/artifacts/packages/tools/contrib-carriers.mts" - "/src/native/sdks/swift/tools/ios-carrier-manifest.mts" - "/src/native/sdks/swift/tools/pinned-native-carrier.mts" + - "/tools/release/github-read.mts" + - "/tools/release/github-core-request-journal.mts" - "/src/native/sdks/swift/tools/swift-carrier-resolver.mts" - "/src/native/sdks/swift/tools/render_swiftpm_release_package.mts" - "/src/native/sdks/swift/tools/prepare-swift-release-consumer.mts" diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.mts index d3c32c635..86dad1fae 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.mts @@ -1209,7 +1209,7 @@ function parseArgs(argv) { if (arg === '--help' || arg === '-h') { console.log( `usage: ${path.basename(import.meta.path)} [--base-asset-dir DIR] [--extension-manifest FILE ...] ` + - `[--base-carrier FILE] [--extension-root DIR] [--repository OWNER/REPO] [--output FILE] [--local-urls]`, + `[--base-carrier FILE] [--base-runtime-version VERSION] [--extension-root DIR] [--repository OWNER/REPO] [--output FILE] [--local-urls]`, ); process.exit(0); } @@ -1218,6 +1218,7 @@ function parseArgs(argv) { index += 1; if (arg === '--base-asset-dir') options.baseAssetDir = value; else if (arg === '--base-carrier') options.baseCarrierManifest = value; + else if (arg === '--base-runtime-version') options.baseRuntimeVersion = value; else if (arg === '--extension-manifest') options.extensionManifests.push(value); else if (arg === '--extension-root') options.extensionManifests.push(...discoveredExtensionManifests(path.resolve(value))); diff --git a/src/native/sdks/swift/tools/pinned-native-carrier.mts b/src/native/sdks/swift/tools/pinned-native-carrier.mts index 007b4f86d..83691163a 100644 --- a/src/native/sdks/swift/tools/pinned-native-carrier.mts +++ b/src/native/sdks/swift/tools/pinned-native-carrier.mts @@ -1,7 +1,10 @@ import { copyFileSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import { currentProductVersionSync } from '../../../../../tools/release/release-artifact-targets.mts'; -import { fetchText } from './render_swiftpm_release_package.mts'; +import { + githubReleaseQueryDeadline, + requestGithubJsonWithRetry, +} from '../../../../../tools/release/github-read.mts'; import { materialize } from './swift-carrier-resolver.mts'; // Source builds use the same-run producer for the current runtime. Independent @@ -21,7 +24,8 @@ export async function pinnedNativeCarrierDirectory({ } const tag = `liboliphaunt-native-v${version}`; const api = 'https://api.github.com/repos/f0rr0/oliphaunt'; - const readJson = async (url) => JSON.parse(await fetchText(url, { fetchImpl })); + const deadlineMs = githubReleaseQueryDeadline(); + const readJson = (url) => requestGithubJsonWithRetry(url, { fetchImpl, deadlineMs }); const release = await readJson(`${api}/releases/tags/${tag}`); if ( release.tag_name !== tag || diff --git a/src/native/sdks/swift/tools/pinned-native-carrier.test.mts b/src/native/sdks/swift/tools/pinned-native-carrier.test.mts index 95ccc576b..4d9e3aca8 100644 --- a/src/native/sdks/swift/tools/pinned-native-carrier.test.mts +++ b/src/native/sdks/swift/tools/pinned-native-carrier.test.mts @@ -35,10 +35,18 @@ test('older runtime pins stage exact published bytes, including paginated assets })); let corrupt = false; let downloads = 0; - const fetchImpl = async (input) => { + let releaseQueries = 0; + const fetchImpl = async (input, options) => { const url = String(input); - if (url.includes('/releases/tags/')) + if (url.startsWith('https://api.github.com/')) { + assert.equal(options.headers.Accept, 'application/vnd.github+json'); + assert.equal(options.redirect, 'error'); + } + if (url.includes('/releases/tags/')) { + releaseQueries += 1; + if (releaseQueries === 1) return new Response('temporary failure', { status: 503 }); return Response.json({ id: 23, tag_name: tag, draft: false, prerelease: false }); + } if (new URL(url).searchParams.get('page') === '1') return Response.json(Array.from({ length: 100 }, (_, id) => ({ name: `other-${id}` }))); if (new URL(url).searchParams.get('page') === '2') return Response.json(assets); @@ -52,6 +60,7 @@ test('older runtime pins stage exact published bytes, including paginated assets try { const options = { version, assetDir: '/current-producer', workRoot, fetchImpl }; const directory = await pinnedNativeCarrierDirectory(options); + assert.equal(releaseQueries, 2); for (const name of names) assert.equal(readFileSync(path.join(directory, name), 'utf8'), name); assert.equal(downloads, 2); await pinnedNativeCarrierDirectory(options); diff --git a/tools/ci/ci-plan.sh b/tools/ci/ci-plan.sh index c94d9781d..ae72f38cb 100644 --- a/tools/ci/ci-plan.sh +++ b/tools/ci/ci-plan.sh @@ -44,7 +44,8 @@ if [[ $# == 0 && ${GITHUB_EVENT_NAME:-} != workflow_dispatch && ${CI_RELEASE_PRO printf '{"tasks":{}}\n' > "$plan_dir/carrier-affected.json" fi if bun -e 'const data=await Bun.file(process.argv[1]).json(); process.exit(Object.hasOwn(data.tasks ?? {}, "liboliphaunt-native:finalize-runtime-ios-abi") ? 1 : 0)' "$plan_dir/carrier-affected.json" && - bun src/native/sdks/swift/tools/ios-carrier-manifest.mts --base-carrier "$carrier_cache/manifest.json" --output "$plan_dir/carrier.json"; then + native_version="$(bun tools/release/product-version.mts dependency-compatibility oliphaunt-react-native oliphaunt-swift liboliphaunt-native)" && + bun src/native/sdks/swift/tools/ios-carrier-manifest.mts --base-carrier "$carrier_cache/manifest.json" --base-runtime-version "$native_version" --output "$plan_dir/carrier.json"; then export OLIPHAUNT_REUSE_IOS_CARRIER=true fi fi diff --git a/tools/ci/ci-release-scope.test.sh b/tools/ci/ci-release-scope.test.sh index ed75a22b0..d03ac5ad1 100644 --- a/tools/ci/ci-release-scope.test.sh +++ b/tools/ci/ci-release-scope.test.sh @@ -77,10 +77,11 @@ export FIXTURE_AFFECTED=true ln -s "$root/src" "$repo/src" mkdir -p target/ci/ios-carrier bun - "$root" "$scratch/carrier.json" <<'JS' -import {writeFileSync, readFileSync} from 'node:fs'; +import {writeFileSync} from 'node:fs'; const root = process.argv[2]; const {iosBaseLegalMetadata} = await import(`${root}/src/native/sdks/swift/tools/ios-carrier-manifest.mts`); -const version = readFileSync(`${root}/src/native/runtime/VERSION`, 'utf8').trim(); +const {productDependencyCompatibilityVersion} = await import(`${root}/tools/release/release-graph.mts`); +const version = productDependencyCompatibilityVersion('oliphaunt-react-native', 'oliphaunt-swift', 'liboliphaunt-native'); const tag = `liboliphaunt-native-v${version}`; const assets = [ ['base-xcframework', `liboliphaunt-${version}-apple-spm-xcframework.zip`, 'zip', 'liboliphaunt.xcframework'], @@ -91,7 +92,7 @@ writeFileSync(process.argv[3], JSON.stringify({schema:'oliphaunt-react-native-io base:{product:'liboliphaunt-native', version, tag, assets}, carriers:[], extensions:[], legal:{base:iosBaseLegalMetadata(), extensions:[]}})); JS -for scenario in missing same-sha invalid-sha corrupt unchanged-ios changed-ios; do +for scenario in missing same-sha invalid-sha corrupt wrong-pin unchanged-ios changed-ios; do expected=false cp "$scratch/carrier.json" target/ci/ios-carrier/manifest.json printf '%s\n' "$MOON_HEAD" > target/ci/ios-carrier/source-sha @@ -101,6 +102,7 @@ for scenario in missing same-sha invalid-sha corrupt unchanged-ios changed-ios; same-sha) expected=true ;; invalid-sha) printf 'invalid\n' > target/ci/ios-carrier/source-sha ;; corrupt) printf '{invalid\n' > target/ci/ios-carrier/manifest.json ;; + wrong-pin) bun -e 'const file=process.argv[1]; const value=await Bun.file(file).json(); value.base.version="999.0.0"; await Bun.write(file, JSON.stringify(value))' target/ci/ios-carrier/manifest.json ;; unchanged-ios) printf '%s\n' "$before" > target/ci/ios-carrier/source-sha; expected=true ;; changed-ios) printf '%s\n' "$before" > target/ci/ios-carrier/source-sha; export FIXTURE_IOS_CHANGED=true ;; esac From 2f58cbba27a04d22e2837ee74c602c9dfc2987b1 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 5 Oct 2026 04:40:40 +0000 Subject: [PATCH 6/6] fix(ci): qualify mobile artifacts against workspace runtimes --- .github/workflows/ci.yml | 32 ++-- src/docs/maintainers/release.md | 4 + src/examples/moon.yml | 2 + src/extensions/artifacts/packages/moon.yml | 2 + src/native/sdks/react-native/moon.yml | 8 + .../tools/mobile-extension-artifact-paths.mts | 12 +- .../mobile-extension-artifact-paths.test.mts | 60 +++++++ .../tools/stage-mobile-qualification.mts | 135 ++++++++++++++ .../tools/stage-mobile-qualification.test.mts | 169 ++++++++++++++++++ .../sdks/swift/tools/ios-carrier-manifest.mts | 3 +- .../swift/tools/ios-carrier-manifest.test.mts | 36 +++- tools/release/publication-lock.mts | 8 + tools/release/publication-lock.test.mts | 25 +++ 13 files changed, 467 insertions(+), 29 deletions(-) create mode 100644 src/native/sdks/react-native/tools/stage-mobile-qualification.mts create mode 100644 src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f793ecae..a5222462a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2969,6 +2969,9 @@ jobs: name: oliphaunt-mobile-extension-package-artifacts-android path: target/extension-artifacts + - name: Stage workspace mobile qualification + run: tools/dev/bun.sh src/native/sdks/react-native/tools/stage-mobile-qualification.mts android target/extension-artifacts target/qualification/mobile-android + - name: Build Android mobile app env: OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS: "0" @@ -2979,7 +2982,7 @@ jobs: OLIPHAUNT_EXPO_ANDROID_EXTENSIONS: ${{ needs.affected.outputs.extension_package_sql_names_csv }} OLIPHAUNT_EXPO_ANDROID_ICU: "1" OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS: "1" - OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT: ${{ github.workspace }}/target/extension-artifacts + OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT: ${{ github.workspace }}/target/qualification/mobile-android/extensions OLIPHAUNT_EXPO_ANDROID_OLIPHAUNT_SO: ${{ github.workspace }}/${{ matrix.build-root }}/out/liboliphaunt.so OLIPHAUNT_EXPO_ANDROID_RUNTIME_DIR: ${{ github.workspace }}/target/liboliphaunt-mobile-host/${{ matrix.target }}/install OLIPHAUNT_MOON_TRANSFERRED_DEPS_JSON: '["oliphaunt-query-ts:package", "database-resources:package-icu", "database-resources:build-native-android-icu", "extension-packages:package-mobile", "liboliphaunt-native:package-runtime-android-x86_64", "liboliphaunt-native:finalize-runtime-android-abi", "oliphaunt-kotlin:package", "oliphaunt-react-native:package"]' @@ -3019,7 +3022,7 @@ jobs: runs-on: macos-26 timeout-minutes: 180 env: - OLIPHAUNT_EXTENSION_ARTIFACT_ROOT: ${{ github.workspace }}/target/mobile-extension-artifacts + OLIPHAUNT_EXTENSION_ARTIFACT_ROOT: ${{ github.workspace }}/target/qualification/mobile-ios/extensions steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -3126,17 +3129,12 @@ jobs: name: oliphaunt-mobile-extension-package-artifacts-ios path: target/mobile-extension-artifacts - - name: Render exact-SHA cache-warm iOS carrier manifest - run: | - tools/dev/bun.sh src/native/sdks/swift/tools/ios-carrier-manifest.mts \ - --base-asset-dir target/liboliphaunt/release-assets \ - --extension-root target/mobile-extension-artifacts \ - --output target/release/ios-carriers/oliphaunt-react-native-ios-carriers.json \ - --local-urls + - name: Stage workspace mobile qualification + run: tools/dev/bun.sh src/native/sdks/react-native/tools/stage-mobile-qualification.mts ios target/mobile-extension-artifacts target/qualification/mobile-ios - name: Qualify every exact iOS carrier env: - IOS_CARRIER_MANIFEST: target/release/ios-carriers/oliphaunt-react-native-ios-carriers.json + IOS_CARRIER_MANIFEST: target/qualification/mobile-ios/ios-carriers/oliphaunt-react-native-ios-carriers.json IOS_CARRIER_STAGE: target/qualification/react-native-ios-all-carriers IOS_CARRIER_CACHE: target/qualification/react-native-ios-carrier-cache PLANNED_EXTENSION_SQL_NAMES: ${{ needs.affected.outputs.extension_package_sql_names_csv }} @@ -3147,15 +3145,16 @@ jobs: env: CI_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} OLIPHAUNT_SWIFT_NATIVE_ASSET_DIR: ${{ github.workspace }}/target/liboliphaunt/release-assets - OLIPHAUNT_SWIFT_SDK_ARTIFACT_DIR: ${{ github.workspace }}/target/sdk-artifacts/oliphaunt-swift + OLIPHAUNT_SWIFT_SDK_ARTIFACT_DIR: ${{ github.workspace }}/target/qualification/mobile-ios/swift-sdk PLANNED_EXTENSION_PRODUCTS: ${{ needs.affected.outputs.extension_package_products_csv }} run: | - source_carrier=target/sdk-artifacts/oliphaunt-swift/release-tree/src/sdks/swift/Carriers/oliphaunt-react-native-ios-carriers.json + published_source_carrier=target/sdk-artifacts/oliphaunt-swift/release-tree/src/sdks/swift/Carriers/oliphaunt-react-native-ios-carriers.json + source_carrier="$OLIPHAUNT_SWIFT_SDK_ARTIFACT_DIR/release-tree/src/sdks/swift/Carriers/oliphaunt-react-native-ios-carriers.json" react_native_source_carrier=target/sdk-artifacts/oliphaunt-react-native/ios-carriers/oliphaunt-react-native-ios-carriers.json - cache_warm_carrier=target/release/ios-carriers/oliphaunt-react-native-ios-carriers.json - if ! cmp -s "$source_carrier" "$react_native_source_carrier"; then + cache_warm_carrier=target/qualification/mobile-ios/ios-carriers/oliphaunt-react-native-ios-carriers.json + if ! cmp -s "$published_source_carrier" "$react_native_source_carrier"; then echo 'Swift and React Native package artifacts disagree on the selection-neutral source carrier.' >&2 - diff -u "$source_carrier" "$react_native_source_carrier" || true + diff -u "$published_source_carrier" "$react_native_source_carrier" || true exit 1 fi consumer_args=( @@ -3165,6 +3164,7 @@ jobs: evidence_dir=target/release/ios-carriers/qualification/swift-independent-carriers mkdir -p "$evidence_dir" cp "$source_carrier" "$evidence_dir/selection-neutral-source-carrier.json" + cp "$published_source_carrier" "$evidence_dir/swift-published-selection-neutral-source-carrier.json" cp "$react_native_source_carrier" "$evidence_dir/react-native-selection-neutral-source-carrier.json" product_count=0 @@ -3211,7 +3211,7 @@ jobs: OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS: "1" OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK: ${{ github.workspace }}/target/liboliphaunt-ios-xcframework/out/liboliphaunt.xcframework OLIPHAUNT_EXPO_IOS_RUNTIME_DIR: ${{ github.workspace }}/target/liboliphaunt-mobile-host/ios-xcframework/install - OLIPHAUNT_REACT_NATIVE_IOS_BASE_CARRIER: ${{ github.workspace }}/target/release/ios-carriers/oliphaunt-react-native-ios-carriers.json + OLIPHAUNT_REACT_NATIVE_IOS_BASE_CARRIER: ${{ github.workspace }}/target/qualification/mobile-ios/ios-carriers/oliphaunt-react-native-ios-carriers.json OLIPHAUNT_MOON_TRANSFERRED_DEPS_JSON: '["oliphaunt-query-ts:package", "database-resources:package-icu", "database-resources:build-native-ios-icu", "extension-packages:package-mobile", "liboliphaunt-native:package-runtime-ios-xcframework", "oliphaunt-react-native:package", "oliphaunt-swift:package"]' run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' .github/scripts/run-planned-moon-job.sh mobile-build-ios diff --git a/src/docs/maintainers/release.md b/src/docs/maintainers/release.md index e3c48493f..b733be98c 100644 --- a/src/docs/maintainers/release.md +++ b/src/docs/maintainers/release.md @@ -216,6 +216,10 @@ Swift and React Native carrier staging also retains the exact native pin. React Native resolves that pin through its declared Swift release, including immutable historical metadata when Swift has advanced. Older runtime pins use verified published Apple archives; current pins require same-run producer assets. +Mobile app qualification stages private extension and Swift carrier fixtures +under `target/qualification` against the current workspace runtimes. It leaves +the release packages intact, retains payload hashes and exact runtime validation, +and marks extension fixtures `qualificationOnly` so publication rejects them. PR CI recognizes generated `chore(release):` changes only on the generated Release Please branch. Before merge it requires the release commit's parent to diff --git a/src/examples/moon.yml b/src/examples/moon.yml index 6ab8d17e0..bc4c2b198 100644 --- a/src/examples/moon.yml +++ b/src/examples/moon.yml @@ -84,6 +84,7 @@ tasks: - project: "extensions" group: "build" - "/target/mobile-extension-artifacts/**/*" + - "/target/qualification/mobile-android/**/*" - "/target/sdk-artifacts/oliphaunt-kotlin/**/*" - "/target/sdk-artifacts/oliphaunt-react-native/**/*" - "/src/native/runtime/packaging/**/*" @@ -145,6 +146,7 @@ tasks: - project: "extensions" group: "build" - "/target/mobile-extension-artifacts/**/*" + - "/target/qualification/mobile-ios/**/*" - "/target/sdk-artifacts/oliphaunt-react-native/**/*" - "/target/sdk-artifacts/oliphaunt-swift/**/*" - "/src/native/runtime/packaging/**/*" diff --git a/src/extensions/artifacts/packages/moon.yml b/src/extensions/artifacts/packages/moon.yml index a21b7fa40..6b6eabcc4 100644 --- a/src/extensions/artifacts/packages/moon.yml +++ b/src/extensions/artifacts/packages/moon.yml @@ -141,6 +141,8 @@ tasks: - requires-rust command: bash src/extensions/artifacts/packages/tools/test.sh inputs: + - /src/native/sdks/swift/tools/ios-carrier-manifest.mts + - /src/native/sdks/swift/tools/swift-source-carrier-contract.mts - /src/native/sdks/rust/crates/oliphaunt-build/src/**/*.rs - /src/native/sdks/rust/crates/oliphaunt-build/Cargo.toml - "@group(cargo-workspace)" diff --git a/src/native/sdks/react-native/moon.yml b/src/native/sdks/react-native/moon.yml index ba6c74b09..617902e7c 100644 --- a/src/native/sdks/react-native/moon.yml +++ b/src/native/sdks/react-native/moon.yml @@ -193,11 +193,19 @@ tasks: - "/tools/dev/bun.sh" - /src/examples/native/react-native-expo/package.json - /tools/packaging/portable-archive.mts + - /tools/packaging/archive-directory.mts + - "@group(release-archive-contract)" + - "@group(legal-files)" + - /src/extensions/tools/extension-upstream-licenses.mts - /src/native/sdks/swift/tools/ios-carrier-manifest.mts - /src/native/sdks/swift/tools/swift-source-carrier-contract.mts - /src/native/sdks/swift/tools/pinned-native-carrier.mts - /src/native/sdks/swift/tools/swift-carrier-resolver.mts - /src/native/sdks/swift/tools/render_swiftpm_release_package.mts + - /src/native/sdks/swift/tools/prepare-swift-release-consumer.mts + - /src/native/sdks/swift/tools/swift-extension-release-consumer-inputs.mts + - /src/native/runtime/VERSION + - /src/wasix/runtime/VERSION - /tools/release/*.{mts,sh} - project: oliphaunt-query-ts group: sources diff --git a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts index 61b4219ce..10f5775a7 100644 --- a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts +++ b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts @@ -101,9 +101,15 @@ const BUNDLE_CARRIER_ASSET_KEYS = new Set([ function manifestEnvelopeKeys(baseKeys, manifest, repositoryContract) { const owner = repositoryContract.products.get(manifest.product); - return owner?.releaseProduct === owner?.artifactProduct - ? baseKeys - : new Set([...baseKeys, 'releaseProduct', 'family']); + const keys = + owner?.releaseProduct === owner?.artifactProduct + ? baseKeys + : new Set([...baseKeys, 'releaseProduct', 'family']); + if (Object.hasOwn(manifest, 'qualificationOnly')) { + if (manifest.qualificationOnly !== true) fail('qualificationOnly must be true when present'); + return new Set([...keys, 'qualificationOnly']); + } + return keys; } class CliFailure extends Error { diff --git a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts index 33382d149..3c23f3d5a 100644 --- a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts +++ b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts @@ -24,6 +24,7 @@ import { extensionCarrierLegalFileInventory, } from '../../../../extensions/tools/extension-upstream-licenses.mts'; import { resolveMobileExtensionArtifactPaths } from './mobile-extension-artifact-paths.mts'; +import { stageMobileQualification } from './stage-mobile-qualification.mts'; const REPOSITORY_ROOT = path.resolve(import.meta.dirname, '../../../../..'); const VERSION = '1.2.3'; @@ -581,6 +582,65 @@ test('materializes aggregate and singleton assets into immutable content-address ]); }); +test('qualifies singleton and bundled workspace bytes without changing release pins or validation', async (t) => { + const value = fixture(t); + const aggregate = value.installAggregate(); + const leaf = value.installLeaf(); + leaf.manifest.compatibility = { + ...COMPATIBILITY, + nativeRuntimeVersion: '0.0.0', + wasixRuntimeVersion: '0.0.0', + }; + value.writeManifest(VECTOR, leaf.manifest); + const original = readFileSync(value.manifestPath(VECTOR)); + assert.equal( + ( + await value.resolve({ + extensions: 'vector', + assetKind: 'runtime', + assetTarget: 'android-x86_64', + }) + ).status, + 1, + ); + const outputRoot = path.join(value.root, 'qualification'); + stageMobileQualification({ platform: 'android', extensionRoot: value.artifactRoot, outputRoot }); + assert.deepEqual(readFileSync(value.manifestPath(VECTOR)), original); + const manifestFile = path.join(outputRoot, 'extensions', VECTOR, 'extension-artifacts.json'); + const manifest = JSON.parse(readFileSync(manifestFile, 'utf8')); + assert.deepEqual(manifest.compatibility, COMPATIBILITY); + assert.equal(manifest.qualificationOnly, true); + assert.equal(manifest.version, VERSION); + const args = [ + '--root', + REPOSITORY_ROOT, + '--artifact-root', + path.join(outputRoot, 'extensions'), + '--materialize-root', + value.materializeRoot, + '--extensions', + 'amcheck,cube,vector', + '--asset-kind', + 'runtime', + '--asset-target', + 'android-x86_64', + '--required', + '1', + ]; + assertContents(await resolveMobileExtensionArtifactPaths(args), [ + aggregate.declaredContents.get('android-x86_64:amcheck:runtime'), + aggregate.declaredContents.get('android-x86_64:cube:runtime'), + leaf.contents.get('android-x86_64:runtime'), + ]); + manifest.qualificationOnly = false; + writeJson(manifestFile, manifest); + await assert.rejects(resolveMobileExtensionArtifactPaths(args), /qualificationOnly must be true/); + manifest.qualificationOnly = true; + manifest.compatibility.nativeRuntimeVersion = '0.0.0'; + writeJson(manifestFile, manifest); + await assert.rejects(resolveMobileExtensionArtifactPaths(args), /must exactly match/); +}); + test('rejects outer and nested carrier tampering independently', async (t) => { const outer = fixture(t); const outerAggregate = outer.installAggregate({ targets: ['android-arm64-v8a'] }); diff --git a/src/native/sdks/react-native/tools/stage-mobile-qualification.mts b/src/native/sdks/react-native/tools/stage-mobile-qualification.mts new file mode 100644 index 000000000..6c863b26d --- /dev/null +++ b/src/native/sdks/react-native/tools/stage-mobile-qualification.mts @@ -0,0 +1,135 @@ +#!/usr/bin/env bun +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { currentProductVersionSync } from '../../../../../tools/release/release-artifact-targets.mts'; +import { ROOT } from '../../../../../tools/release/release-graph.mts'; +import { + discoveredExtensionManifests, + IOS_CARRIER_FILENAME, + swiftExtensionCarrierAssetName, + writeIosCarrierManifest, + writeSwiftExtensionCarrierManifest, +} from '../../swift/tools/ios-carrier-manifest.mts'; +import { parseSwiftReleaseBinaryTarget } from '../../swift/tools/prepare-swift-release-consumer.mts'; + +function writeJson(file, value) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +/** Keep immutable release pins separate from qualification of current workspace bytes. */ +export function stageMobileQualification({ + platform, + extensionRoot, + outputRoot, + baseAssetDir = path.join(ROOT, 'target/liboliphaunt/release-assets'), + swiftSdkDir = path.join(ROOT, 'target/sdk-artifacts/oliphaunt-swift'), +}) { + if (!['android', 'ios'].includes(platform)) + throw new Error(`unknown mobile platform ${platform}`); + if (existsSync(outputRoot)) + throw new Error(`qualification destination already exists: ${outputRoot}`); + const manifests = discoveredExtensionManifests(extensionRoot); + if (manifests.length === 0) throw new Error(`no extension manifests in ${extensionRoot}`); + const nativeRuntimeVersion = currentProductVersionSync('liboliphaunt-native'); + const wasixRuntimeVersion = currentProductVersionSync('liboliphaunt-wasix'); + const extensions = path.join(outputRoot, 'extensions'); + cpSync(extensionRoot, extensions, { recursive: true }); + const stagedManifests = manifests.map((source) => { + const file = path.join(extensions, path.relative(extensionRoot, source)); + const manifest = JSON.parse(readFileSync(file, 'utf8')); + manifest.qualificationOnly = true; + manifest.compatibility = { + ...manifest.compatibility, + nativeRuntimeVersion, + wasixRuntimeVersion, + }; + // Producer paths may name a different transport root. Point at the copied bytes. + for (const asset of [ + ...(manifest.extensions ?? [manifest]).flatMap((member) => member.assets), + ...(manifest.carrierAssets ?? []), + ]) { + const directory = asset.carrierAsset ? 'member-assets' : 'release-assets'; + const marker = `/${directory}/`; + const offset = asset.path.lastIndexOf(marker); + if (offset < 0) throw new Error(`invalid extension asset path ${asset.path}`); + asset.path = path.join( + path.dirname(file), + directory, + asset.path.slice(offset + marker.length), + ); + } + writeJson(file, manifest); + return file; + }); + writeJson(path.join(outputRoot, 'qualification.json'), { + qualificationOnly: true, + nativeRuntimeVersion, + wasixRuntimeVersion, + }); + if (platform === 'android') return; + + const swift = path.join(outputRoot, 'swift-sdk'); + cpSync(swiftSdkDir, swift, { recursive: true }); + writeJson(path.join(swift, 'qualification.json'), { + qualificationOnly: true, + product: 'oliphaunt-swift', + }); + const neutralFile = path.join( + swift, + 'release-tree/src/sdks/swift/Carriers', + IOS_CARRIER_FILENAME, + ); + const neutral = writeIosCarrierManifest(neutralFile, { + baseAssetDir, + baseRuntimeVersion: nativeRuntimeVersion, + extensionManifests: [], + }); + const base = neutral.base.assets.find((asset) => asset.role === 'base-xcframework'); + const releaseManifest = path.join(swift, 'Package.swift.release'); + const original = readFileSync(releaseManifest, 'utf8'); + const target = parseSwiftReleaseBinaryTarget(original); + const replacement = original + .slice(target.index, target.end) + .replace(target.url, base.url) + .replace(target.checksum, base.sha256); + const manifest = original.slice(0, target.index) + replacement + original.slice(target.end); + writeFileSync(releaseManifest, manifest); + writeFileSync(path.join(swift, 'release-tree/Package.swift'), manifest); + writeIosCarrierManifest(path.join(outputRoot, 'ios-carriers', IOS_CARRIER_FILENAME), { + baseAssetDir, + baseRuntimeVersion: nativeRuntimeVersion, + extensionManifests: stagedManifests, + localUrls: true, + }); + for (const extensionManifest of stagedManifests) { + const { product, version } = JSON.parse(readFileSync(extensionManifest, 'utf8')); + writeSwiftExtensionCarrierManifest( + path.join( + path.dirname(extensionManifest), + 'release-assets', + swiftExtensionCarrierAssetName(product, version), + ), + { extensionManifest, nativeRuntimeVersion }, + ); + } +} + +if (import.meta.main) { + try { + const [platform, extensionRoot, outputRoot, ...extra] = Bun.argv.slice(2); + if (!extensionRoot || !outputRoot || extra.length) { + throw new Error( + 'usage: stage-mobile-qualification.mts android|ios EXTENSION_ROOT OUTPUT_ROOT', + ); + } + stageMobileQualification({ + platform, + extensionRoot: path.resolve(extensionRoot), + outputRoot: path.resolve(outputRoot), + }); + } catch (error) { + console.error(`stage-mobile-qualification.mts: ${error.message}`); + process.exit(1); + } +} diff --git a/src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts b/src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts new file mode 100644 index 000000000..4b604cb1c --- /dev/null +++ b/src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { archiveDirectory } from '../../../../../tools/packaging/archive-directory.mts'; +import { stageReleaseNotices } from '../../../../../tools/packaging/release-notices.mts'; +import { + currentProductVersionSync, + extensionMetadata, +} from '../../../../../tools/release/release-artifact-targets.mts'; +import { ROOT } from '../../../../../tools/release/release-graph.mts'; +import { stageExtensionUpstreamLicenses } from '../../../../extensions/tools/extension-upstream-licenses.mts'; +import { + IOS_CARRIER_FILENAME, + buildIosCarrierManifest, +} from '../../swift/tools/ios-carrier-manifest.mts'; +import { + localizeSwiftReleaseManifest, + parseSwiftReleaseBinaryTarget, +} from '../../swift/tools/prepare-swift-release-consumer.mts'; +import { extensionReleaseConsumerInputs } from '../../swift/tools/swift-extension-release-consumer-inputs.mts'; +import { stageMobileQualification } from './stage-mobile-qualification.mts'; + +test('iOS qualification keeps the Swift base, independent carriers and binary checksum aligned', async () => { + mkdirSync(path.join(ROOT, 'target'), { recursive: true }); + const root = mkdtempSync(path.join(ROOT, 'target/mobile-qualification-test-')); + try { + const nativeVersion = currentProductVersionSync('liboliphaunt-native'); + const product = 'oliphaunt-extension-pgtap'; + const version = currentProductVersionSync(product); + const extensionRoot = path.join(root, 'extensions'); + const releaseAssets = path.join(extensionRoot, product, 'release-assets'); + const baseAssetDir = path.join(root, 'base'); + mkdirSync(releaseAssets, { recursive: true }); + mkdirSync(baseAssetDir, { recursive: true }); + async function archive(name, directory, member, profile, sqlName = undefined) { + const stage = path.join(root, `stage-${name}`); + mkdirSync(path.join(stage, member), { recursive: true }); + writeFileSync(path.join(stage, member, 'payload'), 'workspace bytes'); + stageReleaseNotices(member === 'oliphaunt' ? stage : path.join(stage, member), { profile }); + if (sqlName) stageExtensionUpstreamLicenses(sqlName, path.join(stage, 'files')); + const output = path.join(directory, name); + await archiveDirectory(member === 'oliphaunt' ? stage : path.join(stage, member), output, { + keepParent: member !== 'oliphaunt', + }); + return output; + } + const baseArchive = await archive( + `liboliphaunt-${nativeVersion}-apple-spm-xcframework.zip`, + baseAssetDir, + 'liboliphaunt.xcframework', + 'native-runtime', + ); + await archive( + `liboliphaunt-${nativeVersion}-runtime-resources-ios-datum64.tar.gz`, + baseAssetDir, + 'oliphaunt', + 'native-runtime-resources', + ); + const runtime = await archive( + `${product}-${version}-native-ios-runtime.tar.gz`, + releaseAssets, + 'oliphaunt', + 'external-native', + 'pgtap', + ); + const bytes = readFileSync(runtime); + const extensionFile = path.join(extensionRoot, product, 'extension-artifacts.json'); + writeFileSync( + extensionFile, + JSON.stringify({ + schema: 'oliphaunt-extension-ci-artifacts-v1', + product, + version, + compatibility: { + ...extensionMetadata(product).compatibility, + nativeRuntimeVersion: '0.0.0', + }, + sqlName: 'pgtap', + createsExtension: true, + dependencies: [], + dataFiles: [], + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], + sharedPreloadLibraries: [], + nativeModuleStem: null, + iosNativeDependencies: [], + iosRegistration: null, + wasixInstall: null, + assets: [ + { + family: 'native', + target: 'ios-xcframework', + kind: 'runtime', + identity: null, + name: path.basename(runtime), + path: runtime, + source: runtime, + bytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }, + ], + }), + ); + assert.throws( + () => buildIosCarrierManifest({ baseAssetDir, extensionManifests: [extensionFile] }), + /selected base carrier/, + ); + const swiftSdkDir = path.join(root, 'swift'); + mkdirSync(swiftSdkDir, { recursive: true }); + const publishedManifest = `.binaryTarget(name: "liboliphaunt", url: "https://example.invalid/pinned.zip", checksum: "${'0'.repeat(64)}")`; + writeFileSync(path.join(swiftSdkDir, 'Package.swift.release'), publishedManifest); + const original = readFileSync(extensionFile); + const outputRoot = path.join(root, 'qualification'); + stageMobileQualification({ + platform: 'ios', + extensionRoot, + outputRoot, + baseAssetDir, + swiftSdkDir, + }); + const sourceCarrierFile = path.join( + outputRoot, + 'swift-sdk/release-tree/src/sdks/swift/Carriers', + IOS_CARRIER_FILENAME, + ); + const carrier = path.join( + outputRoot, + 'extensions', + product, + 'release-assets', + `${product}-${version}-swift-extension-carrier.json`, + ); + const plan = extensionReleaseConsumerInputs({ + sourceCarrierFile, + extensionCarrierFiles: [carrier], + }); + assert.equal(plan.finalLink.runtimeVersion, nativeVersion); + assert.equal( + JSON.parse(readFileSync(path.join(outputRoot, 'swift-sdk/qualification.json'), 'utf8')) + .qualificationOnly, + true, + ); + const stagedManifest = path.join(outputRoot, 'swift-sdk/Package.swift.release'); + localizeSwiftReleaseManifest({ + manifestFile: stagedManifest, + assetFile: baseArchive, + outputFile: path.join(root, 'localized.swift'), + }); + assert.equal( + parseSwiftReleaseBinaryTarget(readFileSync(stagedManifest, 'utf8')).checksum, + createHash('sha256').update(readFileSync(baseArchive)).digest('hex'), + ); + const warm = JSON.parse( + readFileSync(path.join(outputRoot, 'ios-carriers', IOS_CARRIER_FILENAME), 'utf8'), + ); + assert.equal(warm.base.version, nativeVersion); + assert.ok(warm.base.assets.every((asset) => asset.url.startsWith('file:'))); + assert.equal(JSON.parse(readFileSync(sourceCarrierFile, 'utf8')).extensions.length, 0); + assert.deepEqual(readFileSync(extensionFile), original); + assert.equal( + readFileSync(path.join(swiftSdkDir, 'Package.swift.release'), 'utf8'), + publishedManifest, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.mts index 86dad1fae..7ccaef4ba 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.mts @@ -1128,7 +1128,7 @@ export function buildIosCarrierManifest({ baseAssetDir = path.join(ROOT, 'target/liboliphaunt/release-assets'), baseRuntimeVersion = currentProductVersionSync('liboliphaunt-native', 'ios-carrier-manifest'), baseCarrierManifest = undefined, - extensionManifests = discoveredExtensionManifests(path.join(ROOT, 'target/extension-artifacts')), + extensionManifests = [], repository = DEFAULT_REPOSITORY, localUrls = false, verifyMembers = true, @@ -1226,7 +1226,6 @@ function parseArgs(argv) { else if (arg === '--output') output = path.resolve(value); else throw error(`unknown argument ${arg}`); } - if (options.extensionManifests.length === 0) delete options.extensionManifests; return { options, output }; } diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts index e9b116abd..b9fb1cc61 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, @@ -175,14 +176,33 @@ test('an independently pinned base carrier uses the pinned release and rejects c ); const frozen = path.join(root, 'carrier.json'); writeFileSync(frozen, JSON.stringify(carrier)); - assert.deepEqual( - buildIosCarrierManifest({ - baseCarrierManifest: frozen, - baseRuntimeVersion: version, - extensionManifests: [], - }), - carrier, - ); + const ambient = path.join(ROOT, 'target/extension-artifacts', path.basename(root)); + mkdirSync(ambient, { recursive: true }); + writeFileSync(path.join(ambient, 'extension-artifacts.json'), '{}'); + try { + assert.deepEqual( + buildIosCarrierManifest({ baseCarrierManifest: frozen, baseRuntimeVersion: version }), + carrier, + ); + const output = path.join(root, 'cli-carrier.json'); + const result = spawnSync( + process.execPath, + [ + path.join(import.meta.dir, 'ios-carrier-manifest.mts'), + '--base-carrier', + frozen, + '--base-runtime-version', + version, + '--output', + output, + ], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')), carrier); + } finally { + rmSync(ambient, { recursive: true, force: true }); + } assert.throws( () => buildIosCarrierManifest({ baseCarrierManifest: frozen, extensionManifests: [] }), /does not freeze/, diff --git a/tools/release/publication-lock.mts b/tools/release/publication-lock.mts index 4959e0f97..50eb095e3 100644 --- a/tools/release/publication-lock.mts +++ b/tools/release/publication-lock.mts @@ -1379,6 +1379,9 @@ function extensionGithubReleaseArtifacts(files, product) { (extensionCarrierFamily(product) === null || value?.family === extensionCarrierFamily(product)) ) { + if (Object.hasOwn(value, 'qualificationOnly')) { + throw error(`${product.id} cannot publish a workspace qualification carrier: ${rel(file)}`); + } manifests.push([file, value]); } } @@ -1601,6 +1604,11 @@ function extensionGithubReleaseArtifacts(files, product) { } function swiftReleaseInputs(files, product, { requireExtensionFixture }) { + for (const file of files.filter((file) => path.basename(file) === 'Package.swift.release')) { + if (existsSync(path.join(path.dirname(file), 'qualification.json'))) { + throw error(`${product.id} cannot publish a workspace qualification carrier: ${rel(file)}`); + } + } const expectedFiles = [ ['Oliphaunt-source.zip', 'swiftpm-source-archive'], ['Package.swift.release', 'swiftpm-release-manifest'], diff --git a/tools/release/publication-lock.test.mts b/tools/release/publication-lock.test.mts index 42646705b..f825513d4 100644 --- a/tools/release/publication-lock.test.mts +++ b/tools/release/publication-lock.test.mts @@ -1018,6 +1018,25 @@ describe('publication artifact discovery and freezing', () => { ).toThrow('workspace qualification carrier'); }); + test('rejects qualification extension fixtures only when their product is selected', () => { + const root = temporaryDirectory(); + const product = loadPublicationCatalog('publication-lock.test', { + products: ['oliphaunt-extension-vector'], + }).products[0]; + const { manifestPath } = extensionGithubReleaseFixture(root, product); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + // Presence rejects even a forged false marker. + manifest.qualificationOnly = false; + writeFileSync(manifestPath, JSON.stringify(manifest)); + expect(() => discoverProductArtifacts([root], [product])).toThrow( + 'workspace qualification carrier', + ); + const unrelated = loadPublicationCatalog('publication-lock.test', { + products: ['oliphaunt-js'], + }).products[0]; + expect(discoverProductArtifacts([root], [unrelated])).toEqual([]); + }); + test.each([ 'oliphaunt-broker', 'postgres-tools-native', @@ -1453,6 +1472,12 @@ describe('publication artifact discovery and freezing', () => { [sdk, fixture], ), ).not.toThrow(); + const qualificationMarker = path.join(sdk, 'qualification.json'); + writeFileSync(qualificationMarker, JSON.stringify({ qualificationOnly: true })); + expect(() => discoverProductArtifacts(selectedRoots, catalog.products)).toThrow( + 'workspace qualification carrier', + ); + unlinkSync(qualificationMarker); const frozenFixture = swiftArtifacts.find( ({ id }) => id === 'release-input:swiftpm-extension-consumer-fixture', );