diff --git a/tools/release/release-product-version-coverage.test.mts b/tools/release/release-product-version-coverage.test.mts index 95d0db31b..49aed916e 100644 --- a/tools/release/release-product-version-coverage.test.mts +++ b/tools/release/release-product-version-coverage.test.mts @@ -81,7 +81,7 @@ function writeBase(repo, versions) { write(repo, VECTOR_SOURCE, 'commit = "vector-v1"\n'); } -function writeRuntimeRelease(repo, versions, options = {}) { +function writeRuntimeRelease(repo, versions) { write(repo, '.release-please-manifest.json', manifest(versions)); for (const product of [NATIVE, WASIX]) { const packagePath = PRODUCT_PATHS[product]; @@ -92,7 +92,6 @@ function writeRuntimeRelease(repo, versions, options = {}) { `# Changelog\n\n## ${versions[product]} (2026-07-15)\n`, ); } - write(repo, VECTOR_RELEASE, vectorMetadata(versions[NATIVE], versions[WASIX], options)); } function graph(versions) { @@ -137,12 +136,14 @@ const v2 = { ...v1, [NATIVE]: '2.0.0', [WASIX]: '2.0.0' }; if (phase === 'base') { writeBase(repo, v1); } else if (phase === 'release') { - writeRuntimeRelease(repo, v2, scenario === 'config' ? { sqlName: 'not-vector' } : {}); + writeRuntimeRelease(repo, v2); + if (scenario === 'config') + write(repo, VECTOR_RELEASE, vectorMetadata(v1[NATIVE], v1[WASIX], { sqlName: 'not-vector' })); if (scenario === 'source') write(repo, VECTOR_SOURCE, 'commit = "vector-v2"\n'); } else if (phase === 'assert') { const verify = () => verifyReleaseCommit({ repo, headRef, products: [NATIVE, WASIX] }); if (scenario === 'compatible') { - assert.deepEqual(verify().verifiedDerivedPaths, [VECTOR_RELEASE]); + assert.deepEqual(verify().verifiedDerivedPaths, []); assert.deepEqual( buildPlan(graph(v2), [VECTOR_SOURCE], 'release-coverage-test').releaseProducts, [VECTOR], diff --git a/tools/release/verify-release-commit.mts b/tools/release/verify-release-commit.mts index 0c16db685..1fa39a420 100644 --- a/tools/release/verify-release-commit.mts +++ b/tools/release/verify-release-commit.mts @@ -247,16 +247,26 @@ function derivedVersionRules() { const products = loadProducts(TOOL); const structured = new Map(); const text = new Map(); - const addStructured = (type, file, parts, sourceProduct, wrapped = false) => { + const addStructured = ( + type, + file, + parts, + sourceProduct, + wrapped = false, + consumerProduct = undefined, + ) => { const key = structuredRuleKey(type, file, parts); const prior = structured.get(key); if ( prior !== undefined && - (prior.sourceProduct !== sourceProduct || prior.wrapped !== wrapped) + (prior.sourceProduct !== sourceProduct || + prior.wrapped !== wrapped || + prior.consumerProduct !== consumerProduct) ) { throw error(`conflicting derived version rules for ${file}:${parts.join('.')}`); } - structured.set(key, { sourceProduct, wrapped }); + const rule = { sourceProduct, wrapped, consumerProduct }; + structured.set(key, rule); if ( type === 'json' && file.endsWith('/package.json') && @@ -270,7 +280,7 @@ function derivedVersionRules() { ) { structured.set( structuredRuleKey('jsonc', 'bun.lock', ['workspaces', path.posix.dirname(file), ...parts]), - { sourceProduct, wrapped }, + rule, ); } }; @@ -290,14 +300,26 @@ function derivedVersionRules() { const parser = separator === -1 ? entry.parser : entry.parser.slice(0, separator); const expression = separator === -1 ? '' : entry.parser.slice(separator + 1); if (parser === 'json' || parser === 'toml') { - addStructured(parser, entry.path, expression.split('.'), entry.sourceProduct); + addStructured( + parser, + entry.path, + expression.split('.'), + entry.sourceProduct, + false, + entry.product, + ); } else if (parser === 'raw') { - addText(entry.path, { type: 'raw', sourceProduct: entry.sourceProduct }); + addText(entry.path, { + type: 'raw', + sourceProduct: entry.sourceProduct, + consumerProduct: entry.product, + }); } else if (parser === 'rust-const') { addText(entry.path, { type: 'rust-const', name: expression, sourceProduct: entry.sourceProduct, + consumerProduct: entry.product, }); } else { throw error( @@ -350,7 +372,19 @@ function derivedVersionRules() { return cachedDerivedRules; } -function productTransition(rule, before, after, transitions) { +function productTransition(rule, before, after, transitions, productVersions) { + // Selected consumers may catch up from older pins without a producer release. + if (rule.consumerProduct !== undefined) { + return ( + transitions.some(({ product }) => product === rule.consumerProduct) && + typeof before === 'string' && + SEMVER.test(before) && + typeof after === 'string' && + SEMVER.test(after) && + after === productVersions.get(rule.sourceProduct) && + Bun.semver.order(after, before) > 0 + ); + } const transition = transitions.find(({ product }) => product === rule.sourceProduct); if (transition === undefined) return false; return rule.wrapped @@ -530,7 +564,13 @@ function authorizedDerivedStructuredChange(context, rules) { } const rule = rules.structured.get(structuredRuleKey(context.type, context.file, context.parts)); if (rule !== undefined) - return productTransition(rule, context.before, context.after, context.transitions); + return productTransition( + rule, + context.before, + context.after, + context.transitions, + context.productVersions, + ); if (context.type !== 'toml') return false; return cargoDependencyVersionChange(context) || cargoLockVersionChange(context); } @@ -577,6 +617,7 @@ function validateTextSemanticDiff({ derived, transitions, derivedRules, + productVersions, }) { const before = show(repo, parent, file); const after = show(repo, commit, file); @@ -612,7 +653,7 @@ function validateTextSemanticDiff({ if ( prior === null || next === null || - !productTransition(derivedRule, prior[2], next[2], transitions) || + !productTransition(derivedRule, prior[2], next[2], transitions, productVersions) || before.replace(pattern, '$1$3') !== after.replace(pattern, '$1$3') ) { @@ -622,7 +663,7 @@ function validateTextSemanticDiff({ } if ( derivedRule?.type === 'raw' && - productTransition(derivedRule, before.trim(), after.trim(), transitions) && + productTransition(derivedRule, before.trim(), after.trim(), transitions, productVersions) && before.replace(before.trim(), '') === after.replace(after.trim(), '') ) { @@ -642,6 +683,7 @@ function validateAllowedFileSemantics({ fieldsByFile, derivedFiles, transitions, + productVersions, }) { const derivedRules = derivedVersionRules(); const cargoVersions = new Map(); @@ -660,6 +702,7 @@ function validateAllowedFileSemantics({ derived, transitions, derivedRules, + productVersions, }); continue; } @@ -696,6 +739,7 @@ function validateAllowedFileSemantics({ after: difference.after, transitions, cargoVersions, + productVersions, }, derivedRules, ) @@ -981,6 +1025,9 @@ export function verifyReleaseCommit({ repo = ROOT, headRef = 'HEAD', products }) fieldsByFile, derivedFiles, transitions, + productVersions: new Map( + [...byProduct].map(([product, { packagePath }]) => [product, after[packagePath]]), + ), }); return { diff --git a/tools/release/verify-release-commit.test.mts b/tools/release/verify-release-commit.test.mts index 4be8bc9af..4518581dc 100644 --- a/tools/release/verify-release-commit.test.mts +++ b/tools/release/verify-release-commit.test.mts @@ -104,10 +104,14 @@ function prepareCargo(base) { { type: 'json', path: 'packages/darwin-arm64/package.json', jsonpath: '$.version' }, ], }, + 'src/native/sdks/rust': { 'release-type': 'rust', component: 'oliphaunt-rust' }, }, }); const version = base ? '0.0.0' : '0.1.0'; - json('.release-please-manifest.json', { 'src/native/broker': version }); + json('.release-please-manifest.json', { + 'src/native/broker': version, + 'src/native/sdks/rust': version, + }); write('src/native/broker/Cargo.toml', cargo(broker, version)); const carrier = 'src/native/broker/packages/darwin-arm64'; json(`${carrier}/package.json`, { name: '@oliphaunt/broker-darwin-arm64', version }); @@ -125,13 +129,16 @@ function prepareCargo(base) { if (base) { write('src/native/broker/CHANGELOG.md', '# Changelog\n'); write('src/shared/unrelated/Cargo.toml', cargo('unrelated', '0.0.0')); - } else changelog('src/native/broker', version); - if (base || scenario !== 'unrelated-lock') - write( - 'src/native/sdks/rust/Cargo.toml', - cargo('shadow-sdk', scenario === 'unrelated-package' ? '0.1.0' : '0.0.0') + - `\n[dependencies]\noliphaunt-broker = { path = "../../broker", version = "${version}" }\nunrelated = { path = "../../../../src/shared/unrelated", version = "${scenario === 'unrelated-pin' ? '0.1.0' : '0.0.0'}" }\n`, - ); + write('src/native/sdks/rust/CHANGELOG.md', '# Changelog\n'); + } else { + changelog('src/native/broker', version); + changelog('src/native/sdks/rust', version); + } + write( + 'src/native/sdks/rust/Cargo.toml', + cargo('oliphaunt', scenario === 'unrelated-package' ? '0.2.0' : version) + + `\n[dependencies]\noliphaunt-broker = { path = "../../broker", version = "${version}" }\nunrelated = { path = "../../../../src/shared/unrelated", version = "${scenario === 'unrelated-pin' ? '0.1.0' : '0.0.0'}" }\n`, + ); if (base || ['exact', 'unrelated-lock'].includes(scenario)) write( 'Cargo.lock', @@ -228,6 +235,55 @@ function prepareExample(base) { `[package]\nname = "release-example"\nversion = "0.0.0"\n\n[package.metadata.oliphaunt]\nruntime = "liboliphaunt-native"\nruntime-version = "${runtimeVersion}"\n\n[target.'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))'.dependencies]\nliboliphaunt-native-linux-x64-gnu = { version = "=${carrierVersion}" }\nunrelated = { version = "${unrelatedVersion}" }\n`, ); } +function prepareCompatibility(base) { + const consumers = { + 'src/extensions/external/pg_hashids': simple('oliphaunt-extension-pg-hashids'), + 'src/native/sdks/ts': { 'release-type': 'node', component: 'oliphaunt-js' }, + 'src/native/sdks/rust': { 'release-type': 'rust', component: 'oliphaunt-rust' }, + 'src/native/sdks/swift': simple('oliphaunt-swift'), + }; + const producers = { [nativePath]: simple(native), 'src/native/broker': simple(broker) }; + json('release-please-config.json', { packages: { ...producers, ...consumers } }); + const producerVersion = base || scenario === 'consumer-only' ? '0.3.1' : '0.3.2'; + const consumerVersion = base || scenario === 'unselected-consumer' ? '0.1.0' : '0.2.0'; + const pin = base ? '0.3.0' : producerVersion; + json('.release-please-manifest.json', { + ...Object.fromEntries(Object.keys(producers).map((folder) => [folder, producerVersion])), + ...Object.fromEntries(Object.keys(consumers).map((folder) => [folder, consumerVersion])), + }); + for (const folder of Object.keys(producers)) { + write(`${folder}/VERSION`, `${producerVersion}\n`); + changelog(folder, producerVersion); + } + for (const folder of Object.keys(consumers)) changelog(folder, consumerVersion); + write('src/extensions/external/pg_hashids/VERSION', `${consumerVersion}\n`); + write( + 'src/extensions/external/pg_hashids/release.toml', + `[extension.compatibility]\npostgres_major = "${scenario === 'non-version-edit' ? '19' : '18'}"\nnative_runtime_version = "${scenario === 'wrong-toml-pin' ? '0.4.0' : pin}"\n`, + ); + json('src/native/sdks/ts/package.json', { + name: '@oliphaunt/ts', + version: consumerVersion, + oliphaunt: { + liboliphauntVersion: scenario === 'wrong-json-pin' ? '0.4.0' : pin, + brokerVersion: pin, + }, + }); + write( + 'src/native/sdks/rust/Cargo.toml', + cargo('oliphaunt', consumerVersion) + + `\n[package.metadata.oliphaunt]\nnative-version = "${pin}"\nbroker-version = "${pin}"\n`, + ); + write( + 'src/native/sdks/rust/src/broker.rs', + `const BROKER_RELEASE_VERSION: &str = "${scenario === 'wrong-rust-const' ? '0.4.0' : pin}";\n`, + ); + write('src/native/sdks/swift/VERSION', `${consumerVersion}\n`); + write( + 'src/native/sdks/swift/LIBOLIPHAUNT_VERSION', + `${scenario === 'wrong-raw-pin' ? '0.4.0' : pin}\n`, + ); +} if (phase === 'write') { if (scenario === 'later-fix') write('fix.txt', 'post-release fix\n'); else { @@ -238,19 +294,34 @@ if (phase === 'write') { wildcard: prepareWildcard, wasix: prepareWasix, example: prepareExample, + compatibility: prepareCompatibility, }[family]; if (!prepare) throw new Error('unknown release fixture family'); prepare(scenario === 'base'); } } else if (phase === 'assert') { const products = - family === 'wasix' - ? ['liboliphaunt-wasix', 'oliphaunt-wasix-ts'] - : family === 'example' && scenario !== 'missing-native-transition' - ? [native] - : scenario === 'hidden-version-config' - ? ['beta'] - : [broker]; + family === 'compatibility' + ? [ + ...(scenario === 'consumer-only' ? [] : [native, broker]), + ...(scenario === 'unselected-consumer' + ? [] + : [ + 'oliphaunt-extension-pg-hashids', + 'oliphaunt-js', + 'oliphaunt-rust', + 'oliphaunt-swift', + ]), + ].sort() + : family === 'cargo' + ? [broker, 'oliphaunt-rust'].sort() + : family === 'wasix' + ? ['liboliphaunt-wasix', 'oliphaunt-wasix-ts'] + : family === 'example' && scenario !== 'missing-native-transition' + ? [native] + : scenario === 'hidden-version-config' + ? ['beta'] + : [broker]; const verify = () => verifyReleaseCommit({ repo, headRef, products }); if (scenario === 'base') assert.equal(latestVerifiedReleaseCommit({ repo, headRef }), null); else if (scenario === 'later-fix') { @@ -268,10 +339,11 @@ if (phase === 'write') { rename: /non-release-derived path.*src\/future-version[.]txt/u, 'hidden-version-config': /canonical version file.*non-version semantic change/u, 'hidden-derived-config': /derived file.*non-version semantic change/u, + 'derived-version-only': /derived file.*oliphaunt[.]brokerVersion/u, 'unrelated-derived-dependency': /derived file.*optionalDependencies[.]@oliphaunt\/unrelated/u, - 'unrelated-pin': /derived file.*dependencies[.]unrelated[.]version/u, - 'unrelated-package': /derived file.*package[.]version/u, + 'unrelated-pin': /canonical version file.*dependencies[.]unrelated[.]version/u, + 'unrelated-package': /canonical version file.*Cargo[.]toml contains/u, 'unrelated-lock': /derived file.*package[.]1[.]version/u, 'wrong-workspace-version': /workspace lock version.*non-version semantic change/u, 'unrelated-workspace-version': /derived file.*workspaces[.]unrelated[.]version/u, @@ -281,6 +353,12 @@ if (phase === 'write') { 'unrelated-registry-version': /derived file.*unrelated[.]version/u, 'missing-native-transition': /derived file src\/examples\/native\/tauri\/src-tauri\/Cargo[.]toml contains a non-version semantic change/u, + 'unselected-consumer': /derived file.*extension[.]compatibility[.]native_runtime_version/u, + 'wrong-toml-pin': /derived file.*extension[.]compatibility[.]native_runtime_version/u, + 'wrong-json-pin': /canonical version file.*oliphaunt[.]liboliphauntVersion/u, + 'wrong-raw-pin': /derived file.*LIBOLIPHAUNT_VERSION.*non-version semantic change/u, + 'wrong-rust-const': /derived file.*broker[.]rs.*non-version semantic change/u, + 'non-version-edit': /derived file.*extension[.]compatibility[.]postgres_major/u, }[scenario] ?? (family === 'wildcard' && scenario !== 'workspace-wildcard' ? /canonical version file.*non-version semantic change/u diff --git a/tools/release/verify-release-commit.test.sh b/tools/release/verify-release-commit.test.sh index 28f3d5d93..c6482f150 100644 --- a/tools/release/verify-release-commit.test.sh +++ b/tools/release/verify-release-commit.test.sh @@ -15,7 +15,7 @@ assert_history() { bash "$root/tools/release/with-release-history.sh" "$repo" "$head" \ bash "$root/tools/dev/bun.sh" "$fixture" assert "$repo" "$family" "$1" "$head" "${release:-}" } -for family in bootstrap basic cargo wildcard wasix example; do +for family in bootstrap basic cargo wildcard wasix example compatibility; do repo="$scratch/$family" git init -q "$repo" git -C "$repo" config user.name 'Release Test' @@ -31,6 +31,7 @@ for family in bootstrap basic cargo wildcard wasix example; do wildcard) scenarios=(workspace-wildcard local-version wrong-version changed-path removed-path changed-features) ;; wasix) scenarios=(workspace-links) ;; example) scenarios=(exact wrong-registry-version wrong-runtime-version unrelated-registry-version missing-native-transition) ;; + compatibility) scenarios=(with-producer consumer-only unselected-consumer wrong-toml-pin wrong-json-pin wrong-raw-pin wrong-rust-const non-version-edit) ;; esac for scenario in "${scenarios[@]}"; do start="$base"