diff --git a/.github/scripts/bump-versions.mjs b/.github/scripts/bump-versions.mjs index c224ec5dc41..78f660d73d5 100644 --- a/.github/scripts/bump-versions.mjs +++ b/.github/scripts/bump-versions.mjs @@ -11,7 +11,7 @@ const exec = promisify(child_process.exec); /** * @param {string | semver.SemVer} currentVersion */ -function generateExperimentalVersion(currentVersion) { +export function generateExperimentalVersion(currentVersion) { const parsed = semver.parse(currentVersion); if (!parsed) throw new Error(`Invalid version: ${currentVersion}`); @@ -28,84 +28,31 @@ function generateExperimentalVersion(currentVersion) { return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.0`; } -const rootDir = process.cwd(); - -const releaseType = /** @type { import('semver').ReleaseType | "experimental" } */ ( - process.env.RELEASE_TYPE -); -assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE'); - -// TODO: if releaseType is `auto` determine release type based on the changelog - -const lastTag = (await exec('git describe --tags --match "n8n@*" --abbrev=0')).stdout.trim(); -const packages = JSON.parse( - ( - await exec( - `pnpm ls -r --only-projects --json | jq -r '[.[] | { name: .name, version: .version, path: .path, private: .private}]'`, - ) - ).stdout, -); - -const packageMap = {}; -for (let { name, path, version, private: isPrivate } of packages) { - if (isPrivate && path !== rootDir) { - continue; - } - if (path === rootDir) { - name = 'monorepo-root'; - } - - const isDirty = await exec(`git diff --quiet HEAD ${lastTag} -- ${path}`) - .then(() => false) - .catch((error) => true); - - packageMap[name] = { path, isDirty, version }; +/** + * @param {{ pnpm?: { overrides?: Record }, overrides?: Record }} pkg + * @returns {Record} + */ +export function getOverrides(pkg) { + return { ...pkg.pnpm?.overrides, ...pkg.overrides }; } -assert.ok( - Object.values(packageMap).some(({ isDirty }) => isDirty), - 'No changes found since the last release', -); - -// Propagate isDirty transitively: if a package's dependency will be bumped, -// that package also needs a bump (e.g. design-system → editor-ui → cli). - -// Detect root-level changes that affect resolved dep versions without touching individual -// package.json files: pnpm.overrides (applies to all specifiers) -// and pnpm-workspace.yaml catalog entries (applies only to deps using a "catalog:…" specifier). - -const rootPkgJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf-8')); -const rootPkgJsonAtTag = await exec(`git show ${lastTag}:package.json`) - .then(({ stdout }) => JSON.parse(stdout)) - .catch(() => ({})); - -const getOverrides = (pkg) => ({ ...pkg.pnpm?.overrides, ...pkg.overrides }); - -const currentOverrides = getOverrides(rootPkgJson); -const previousOverrides = getOverrides(rootPkgJsonAtTag); - -const changedOverrides = new Set( - Object.keys({ ...currentOverrides, ...previousOverrides }).filter( - (k) => currentOverrides[k] !== previousOverrides[k], - ), -); - -const parseWorkspaceYaml = (content) => { +/** + * @param {string} content + * @returns {Record} + */ +export function parseWorkspaceYaml(content) { try { return /** @type {Record} */ (parse(content) ?? {}); } catch { return {}; } -}; -const workspaceYaml = parseWorkspaceYaml( - await readFile(resolve(rootDir, 'pnpm-workspace.yaml'), 'utf-8').catch(() => ''), -); -const workspaceYamlAtTag = parseWorkspaceYaml( - await exec(`git show ${lastTag}:pnpm-workspace.yaml`) - .then(({ stdout }) => stdout) - .catch(() => ''), -); -const getCatalogs = (ws) => { +} + +/** + * @param {Record} ws + * @returns {Map>} + */ +export function getCatalogs(ws) { const result = new Map(); if (ws.catalog) { result.set('default', /** @type {Record} */ (ws.catalog)); @@ -116,98 +63,232 @@ const getCatalogs = (ws) => { } return result; -}; -// changedCatalogEntries: Map> -const currentCatalogs = getCatalogs(workspaceYaml); -const previousCatalogs = getCatalogs(workspaceYamlAtTag); -const changedCatalogEntries = new Map(); -for (const catalogName of new Set([...currentCatalogs.keys(), ...previousCatalogs.keys()])) { - const current = currentCatalogs.get(catalogName) ?? {}; - const previous = previousCatalogs.get(catalogName) ?? {}; - const changedDeps = new Set( - Object.keys({ ...current, ...previous }).filter((dep) => current[dep] !== previous[dep]), - ); - if (changedDeps.size > 0) { - changedCatalogEntries.set(catalogName, changedDeps); - } } -// Store full dep objects (with specifiers) so we can inspect "catalog:…" values below. -const depsByPackage = {}; -for (const packageName in packageMap) { - const packageFile = resolve(packageMap[packageName].path, 'package.json'); - const packageJson = JSON.parse(await readFile(packageFile, 'utf-8')); - depsByPackage[packageName] = /** @type {Record} */ ( - packageJson.dependencies ?? {} +/** + * @param {Record} currentOverrides + * @param {Record} previousOverrides + * @returns {Set} + */ +export function computeChangedOverrides(currentOverrides, previousOverrides) { + return new Set( + Object.keys({ ...currentOverrides, ...previousOverrides }).filter( + (k) => currentOverrides[k] !== previousOverrides[k], + ), ); } -// Mark packages dirty if any dep had a root-level override or catalog version change. -for (const [packageName, deps] of Object.entries(depsByPackage)) { - if (packageMap[packageName].isDirty) continue; - for (const [dep, specifier] of Object.entries(deps)) { - if (changedOverrides.has(dep)) { - packageMap[packageName].isDirty = true; - break; +/** + * @param {Map>} currentCatalogs + * @param {Map>} previousCatalogs + * @returns {Map>} + */ +export function computeChangedCatalogEntries(currentCatalogs, previousCatalogs) { + const changedCatalogEntries = new Map(); + for (const catalogName of new Set([...currentCatalogs.keys(), ...previousCatalogs.keys()])) { + const current = currentCatalogs.get(catalogName) ?? {}; + const previous = previousCatalogs.get(catalogName) ?? {}; + const changedDeps = new Set( + Object.keys({ ...current, ...previous }).filter((dep) => current[dep] !== previous[dep]), + ); + if (changedDeps.size > 0) { + changedCatalogEntries.set(catalogName, changedDeps); } - if (typeof specifier === 'string' && specifier.startsWith('catalog:')) { - const catalogName = specifier === 'catalog:' ? 'default' : specifier.slice(8); - if (changedCatalogEntries.get(catalogName)?.has(dep)) { + } + return changedCatalogEntries; +} + +/** + * Mark packages as dirty if any dep had a root-level override or catalog version change. + * Mutates packageMap in place. + * + * @param {Record} packageMap + * @param {Record>} depsByPackage + * @param {Set} changedOverrides + * @param {Map>} changedCatalogEntries + */ +export function markDirtyByRootChanges( + packageMap, + depsByPackage, + changedOverrides, + changedCatalogEntries, +) { + for (const [packageName, deps] of Object.entries(depsByPackage)) { + if (packageMap[packageName].isDirty) continue; + for (const [dep, specifier] of Object.entries(deps)) { + if (changedOverrides.has(dep)) { packageMap[packageName].isDirty = true; break; } + if (typeof specifier === 'string' && specifier.startsWith('catalog:')) { + const catalogName = specifier === 'catalog:' ? 'default' : specifier.slice(8); + if (changedCatalogEntries.get(catalogName)?.has(dep)) { + packageMap[packageName].isDirty = true; + break; + } + } } } } -let changed = true; -while (changed) { - changed = false; - for (const packageName in packageMap) { - if (packageMap[packageName].isDirty) continue; - if (Object.keys(depsByPackage[packageName]).some((dep) => packageMap[dep]?.isDirty)) { - packageMap[packageName].isDirty = true; - changed = true; +/** + * Propagate isDirty transitively: if a package's dependency will be bumped, + * that package also needs a bump. Mutates packageMap in place. + * + * @param {Record} packageMap + * @param {Record>} depsByPackage + */ +export function propagateDirtyTransitively(packageMap, depsByPackage) { + let changed = true; + while (changed) { + changed = false; + for (const packageName in packageMap) { + if (packageMap[packageName].isDirty) continue; + if (Object.keys(depsByPackage[packageName]).some((dep) => packageMap[dep]?.isDirty)) { + packageMap[packageName].isDirty = true; + changed = true; + } } } } -// Keep the monorepo version up to date with the released version -packageMap['monorepo-root'].version = packageMap['n8n'].version; - -for (const packageName in packageMap) { - const { path, version, isDirty } = packageMap[packageName]; - const packageFile = resolve(path, 'package.json'); - const packageJson = JSON.parse(await readFile(packageFile, 'utf-8')); - - const dependencyIsDirty = Object.keys(packageJson.dependencies || {}).some( - (dependencyName) => packageMap[dependencyName]?.isDirty, - ); - - let newVersion = version; - - if (isDirty || dependencyIsDirty) { - switch (releaseType) { - case 'experimental': - newVersion = generateExperimentalVersion(version); - break; - case 'premajor': - newVersion = semver.inc( +/** + * @param {string} version + * @param {import('semver').ReleaseType | 'experimental'} releaseType + * @returns {string} + */ +export function computeNewVersion(version, releaseType) { + switch (releaseType) { + case 'experimental': + return generateExperimentalVersion(version); + case 'premajor': + return /** @type {string} */ ( + semver.inc( version, version.includes('-rc.') ? 'prerelease' : 'premajor', undefined, 'rc', - ); - break; - default: - newVersion = semver.inc(version, releaseType); - break; - } + ) + ); + default: + return /** @type {string} */ (semver.inc(version, releaseType)); } - - packageJson.version = packageMap[packageName].nextVersion = newVersion; - - await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n'); } -console.log(packageMap['n8n'].nextVersion); +async function bumpVersions() { + const rootDir = process.cwd(); + + const releaseType = /** @type { import('semver').ReleaseType | "experimental" } */ ( + process.env.RELEASE_TYPE + ); + assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE'); + + // TODO: if releaseType is `auto` determine release type based on the changelog + + const lastTag = (await exec('git describe --tags --match "n8n@*" --abbrev=0')).stdout.trim(); + const packages = JSON.parse( + ( + await exec( + `pnpm ls -r --only-projects --json | jq -r '[.[] | { name: .name, version: .version, path: .path, private: .private}]'`, + ) + ).stdout, + ); + + /** @type {Record} */ + const packageMap = {}; + for (let { name, path, version, private: isPrivate } of packages) { + if (isPrivate && path !== rootDir) { + continue; + } + if (path === rootDir) { + name = 'monorepo-root'; + } + + const isDirty = await exec(`git diff --quiet HEAD ${lastTag} -- ${path}`) + .then(() => false) + .catch(() => true); + + packageMap[name] = { path, isDirty, version }; + } + + assert.ok( + Object.values(packageMap).some(({ isDirty }) => isDirty), + 'No changes found since the last release', + ); + + // Propagate isDirty transitively: if a package's dependency will be bumped, + // that package also needs a bump (e.g. design-system → editor-ui → cli). + + // Detect root-level changes that affect resolved dep versions without touching individual + // package.json files: pnpm.overrides (applies to all specifiers) + // and pnpm-workspace.yaml catalog entries (applies only to deps using a "catalog:…" specifier). + + const rootPkgJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf-8')); + const rootPkgJsonAtTag = await exec(`git show ${lastTag}:package.json`) + .then(({ stdout }) => JSON.parse(stdout)) + .catch(() => ({})); + + const changedOverrides = computeChangedOverrides( + getOverrides(rootPkgJson), + getOverrides(rootPkgJsonAtTag), + ); + + const workspaceYaml = parseWorkspaceYaml( + await readFile(resolve(rootDir, 'pnpm-workspace.yaml'), 'utf-8').catch(() => ''), + ); + const workspaceYamlAtTag = parseWorkspaceYaml( + await exec(`git show ${lastTag}:pnpm-workspace.yaml`) + .then(({ stdout }) => stdout) + .catch(() => ''), + ); + const changedCatalogEntries = computeChangedCatalogEntries( + getCatalogs(workspaceYaml), + getCatalogs(workspaceYamlAtTag), + ); + + // Store full dep objects (with specifiers) so we can inspect "catalog:…" values below. + /** @type {Record>} */ + const depsByPackage = {}; + for (const packageName in packageMap) { + const packageFile = resolve(packageMap[packageName].path, 'package.json'); + const packageJson = JSON.parse(await readFile(packageFile, 'utf-8')); + depsByPackage[packageName] = /** @type {Record} */ ( + packageJson.dependencies ?? {} + ); + } + + // Mark packages dirty if any dep had a root-level override or catalog version change. + markDirtyByRootChanges(packageMap, depsByPackage, changedOverrides, changedCatalogEntries); + + propagateDirtyTransitively(packageMap, depsByPackage); + + // Keep the monorepo version up to date with the released version + packageMap['monorepo-root'].version = packageMap['n8n'].version; + + for (const packageName in packageMap) { + const { path, version, isDirty } = packageMap[packageName]; + const packageFile = resolve(path, 'package.json'); + const packageJson = JSON.parse(await readFile(packageFile, 'utf-8')); + + const dependencyIsDirty = Object.keys(packageJson.dependencies || {}).some( + (dependencyName) => packageMap[dependencyName]?.isDirty, + ); + + let newVersion = version; + + if (isDirty || dependencyIsDirty) { + newVersion = computeNewVersion(version, releaseType); + } + + packageJson.version = packageMap[packageName].nextVersion = newVersion; + + await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n'); + } + + console.log(packageMap['n8n'].nextVersion); +} + +// only run when executed directly, not when imported by tests +if (import.meta.url === `file://${process.argv[1]}`) { + bumpVersions(); +} diff --git a/.github/scripts/bump-versions.test.mjs b/.github/scripts/bump-versions.test.mjs new file mode 100644 index 00000000000..88d03a133bc --- /dev/null +++ b/.github/scripts/bump-versions.test.mjs @@ -0,0 +1,380 @@ +/** + * Run these tests with: + * + * node --test ./.github/scripts/bump-versions.test.mjs + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + generateExperimentalVersion, + getOverrides, + parseWorkspaceYaml, + getCatalogs, + computeChangedOverrides, + computeChangedCatalogEntries, + markDirtyByRootChanges, + propagateDirtyTransitively, + computeNewVersion, +} from './bump-versions.mjs'; + +describe('generateExperimentalVersion', () => { + it('creates -exp.0 from a stable version', () => { + assert.equal(generateExperimentalVersion('1.2.3'), '1.2.3-exp.0'); + }); + + it('increments exp minor when already at exp.0', () => { + assert.equal(generateExperimentalVersion('1.2.3-exp.0'), '1.2.3-exp.1'); + }); + + it('increments exp minor when already at exp.5', () => { + assert.equal(generateExperimentalVersion('1.2.3-exp.5'), '1.2.3-exp.6'); + }); + + it('creates -exp.0 from a version with a different pre-release tag', () => { + assert.equal(generateExperimentalVersion('1.2.3-beta.1'), '1.2.3-exp.0'); + }); + + it('handles multi-digit version numbers', () => { + assert.equal(generateExperimentalVersion('10.20.30'), '10.20.30-exp.0'); + }); + + it('throws on an invalid version string', () => { + assert.throws(() => generateExperimentalVersion('not-a-version'), /Invalid version/); + }); +}); + +describe('getOverrides', () => { + it('returns empty object when no overrides exist', () => { + assert.deepEqual(getOverrides({}), {}); + }); + + it('returns pnpm.overrides when only pnpm.overrides is set', () => { + assert.deepEqual(getOverrides({ pnpm: { overrides: { lodash: '^4.0.0' } } }), { + lodash: '^4.0.0', + }); + }); + + it('returns overrides when only top-level overrides is set', () => { + assert.deepEqual(getOverrides({ overrides: { lodash: '^4.0.0' } }), { lodash: '^4.0.0' }); + }); + + it('merges both fields with top-level overrides taking precedence for the same key', () => { + assert.deepEqual( + getOverrides({ + pnpm: { overrides: { lodash: '^3.0.0', underscore: '^1.0.0' } }, + overrides: { lodash: '^4.0.0' }, + }), + { lodash: '^4.0.0', underscore: '^1.0.0' }, + ); + }); +}); + +describe('parseWorkspaceYaml', () => { + it('parses valid YAML into an object', () => { + assert.deepEqual(parseWorkspaceYaml('catalog:\n lodash: "^4.0.0"'), { + catalog: { lodash: '^4.0.0' }, + }); + }); + + it('returns empty object for an empty string', () => { + assert.deepEqual(parseWorkspaceYaml(''), {}); + }); + + it('returns empty object for invalid YAML', () => { + assert.deepEqual(parseWorkspaceYaml(': - invalid: [yaml}'), {}); + }); +}); + +describe('getCatalogs', () => { + it('returns empty map when no catalog or catalogs field exists', () => { + assert.equal(getCatalogs({}).size, 0); + }); + + it('returns a "default" entry for the top-level catalog field', () => { + const result = getCatalogs({ catalog: { lodash: '^4.0.0' } }); + assert.equal(result.size, 1); + assert.deepEqual(result.get('default'), { lodash: '^4.0.0' }); + }); + + it('returns named entries from the catalogs field', () => { + const result = getCatalogs({ catalogs: { react18: { react: '^18.0.0' } } }); + assert.equal(result.size, 1); + assert.deepEqual(result.get('react18'), { react: '^18.0.0' }); + }); + + it('returns both default and named catalog entries when both fields are present', () => { + const result = getCatalogs({ + catalog: { lodash: '^4.0.0' }, + catalogs: { react18: { react: '^18.0.0' } }, + }); + assert.equal(result.size, 2); + assert.deepEqual(result.get('default'), { lodash: '^4.0.0' }); + assert.deepEqual(result.get('react18'), { react: '^18.0.0' }); + }); +}); + +describe('computeChangedOverrides', () => { + it('returns empty set when nothing changed', () => { + assert.equal(computeChangedOverrides({ lodash: '^4' }, { lodash: '^4' }).size, 0); + }); + + it('detects an added override', () => { + const result = computeChangedOverrides({ lodash: '^4' }, {}); + assert.ok(result.has('lodash')); + }); + + it('detects a removed override', () => { + const result = computeChangedOverrides({}, { lodash: '^4' }); + assert.ok(result.has('lodash')); + }); + + it('detects a changed override value', () => { + const result = computeChangedOverrides({ lodash: '^4' }, { lodash: '^3' }); + assert.ok(result.has('lodash')); + }); + + it('does not include unchanged overrides', () => { + const result = computeChangedOverrides( + { lodash: '^4', underscore: '^1' }, + { lodash: '^4', underscore: '^1' }, + ); + assert.equal(result.size, 0); + }); + + it('handles mixed changed and unchanged overrides', () => { + const result = computeChangedOverrides( + { lodash: '^4', underscore: '^2' }, + { lodash: '^4', underscore: '^1' }, + ); + assert.equal(result.size, 1); + assert.ok(result.has('underscore')); + assert.ok(!result.has('lodash')); + }); +}); + +describe('computeChangedCatalogEntries', () => { + it('returns empty map when nothing changed', () => { + const current = new Map([['default', { lodash: '^4' }]]); + const previous = new Map([['default', { lodash: '^4' }]]); + assert.equal(computeChangedCatalogEntries(current, previous).size, 0); + }); + + it('detects an added dep in a catalog', () => { + const current = new Map([['default', { lodash: '^4' }]]); + const previous = new Map([['default', {}]]); + const result = computeChangedCatalogEntries(current, previous); + assert.ok(result.get('default')?.has('lodash')); + }); + + it('detects a removed dep from a catalog', () => { + const current = new Map([['default', {}]]); + const previous = new Map([['default', { lodash: '^4' }]]); + const result = computeChangedCatalogEntries(current, previous); + assert.ok(result.get('default')?.has('lodash')); + }); + + it('detects a changed dep version in a catalog', () => { + const current = new Map([['default', { lodash: '^4' }]]); + const previous = new Map([['default', { lodash: '^3' }]]); + const result = computeChangedCatalogEntries(current, previous); + assert.ok(result.get('default')?.has('lodash')); + }); + + it('detects changes in a named catalog', () => { + const current = new Map([['react18', { react: '^18' }]]); + const previous = new Map([['react18', { react: '^17' }]]); + const result = computeChangedCatalogEntries(current, previous); + assert.ok(result.get('react18')?.has('react')); + }); + + it('detects a newly added catalog', () => { + const current = new Map([['newCatalog', { lodash: '^4' }]]); + const previous = new Map(); + const result = computeChangedCatalogEntries(current, previous); + assert.ok(result.get('newCatalog')?.has('lodash')); + }); + + it('detects a removed catalog', () => { + const current = new Map(); + const previous = new Map([['oldCatalog', { lodash: '^4' }]]); + const result = computeChangedCatalogEntries(current, previous); + assert.ok(result.get('oldCatalog')?.has('lodash')); + }); + + it('does not include a catalog that has no changed entries', () => { + const current = new Map([ + ['default', { lodash: '^4' }], + ['react18', { react: '^18' }], + ]); + const previous = new Map([ + ['default', { lodash: '^3' }], + ['react18', { react: '^18' }], + ]); + const result = computeChangedCatalogEntries(current, previous); + assert.ok(result.has('default')); + assert.ok(!result.has('react18')); + }); +}); + +describe('markDirtyByRootChanges', () => { + it('marks a package dirty when its dep appears in changedOverrides', () => { + const packageMap = { 'pkg-a': { isDirty: false } }; + const depsByPackage = { 'pkg-a': { lodash: '^4' } }; + markDirtyByRootChanges(packageMap, depsByPackage, new Set(['lodash']), new Map()); + assert.ok(packageMap['pkg-a'].isDirty); + }); + + it('skips already-dirty packages', () => { + const packageMap = { 'pkg-a': { isDirty: true } }; + // No deps, but package is already dirty — should not throw or change state + const depsByPackage = { 'pkg-a': {} }; + markDirtyByRootChanges(packageMap, depsByPackage, new Set(['lodash']), new Map()); + assert.ok(packageMap['pkg-a'].isDirty); + }); + + it('marks a package dirty when its dep uses "catalog:" (default catalog) and that entry changed', () => { + const packageMap = { 'pkg-a': { isDirty: false } }; + const depsByPackage = { 'pkg-a': { lodash: 'catalog:' } }; + const changedCatalogEntries = new Map([['default', new Set(['lodash'])]]); + markDirtyByRootChanges(packageMap, depsByPackage, new Set(), changedCatalogEntries); + assert.ok(packageMap['pkg-a'].isDirty); + }); + + it('marks a package dirty when its dep uses "catalog:" and that named catalog entry changed', () => { + const packageMap = { 'pkg-a': { isDirty: false } }; + const depsByPackage = { 'pkg-a': { react: 'catalog:react18' } }; + const changedCatalogEntries = new Map([['react18', new Set(['react'])]]); + markDirtyByRootChanges(packageMap, depsByPackage, new Set(), changedCatalogEntries); + assert.ok(packageMap['pkg-a'].isDirty); + }); + + it('does not mark a package dirty when none of its deps changed', () => { + const packageMap = { 'pkg-a': { isDirty: false } }; + const depsByPackage = { 'pkg-a': { lodash: '^4' } }; + markDirtyByRootChanges(packageMap, depsByPackage, new Set(['underscore']), new Map()); + assert.ok(!packageMap['pkg-a'].isDirty); + }); + + it('does not mark a package dirty when a catalog: dep is in a catalog with no changes', () => { + const packageMap = { 'pkg-a': { isDirty: false } }; + const depsByPackage = { 'pkg-a': { lodash: 'catalog:' } }; + const changedCatalogEntries = new Map([['default', new Set(['underscore'])]]); + markDirtyByRootChanges(packageMap, depsByPackage, new Set(), changedCatalogEntries); + assert.ok(!packageMap['pkg-a'].isDirty); + }); + + it('does not mark a package dirty when a catalog: dep is in a different catalog than the one that changed', () => { + const packageMap = { 'pkg-a': { isDirty: false } }; + const depsByPackage = { 'pkg-a': { react: 'catalog:react18' } }; + const changedCatalogEntries = new Map([['default', new Set(['react'])]]); + markDirtyByRootChanges(packageMap, depsByPackage, new Set(), changedCatalogEntries); + assert.ok(!packageMap['pkg-a'].isDirty); + }); +}); + +describe('propagateDirtyTransitively', () => { + it('does nothing when no packages are dirty', () => { + const packageMap = { + 'pkg-a': { isDirty: false }, + 'pkg-b': { isDirty: false }, + }; + const depsByPackage = { + 'pkg-a': { 'pkg-b': 'workspace:*' }, + 'pkg-b': {}, + }; + propagateDirtyTransitively(packageMap, depsByPackage); + assert.ok(!packageMap['pkg-a'].isDirty); + assert.ok(!packageMap['pkg-b'].isDirty); + }); + + it('propagates dirty state one level up the dependency chain', () => { + const packageMap = { + 'pkg-a': { isDirty: false }, + 'pkg-b': { isDirty: true }, + }; + const depsByPackage = { + 'pkg-a': { 'pkg-b': 'workspace:*' }, + 'pkg-b': {}, + }; + propagateDirtyTransitively(packageMap, depsByPackage); + assert.ok(packageMap['pkg-a'].isDirty); + }); + + it('propagates dirty state through multiple levels', () => { + const packageMap = { + 'pkg-a': { isDirty: false }, + 'pkg-b': { isDirty: false }, + 'pkg-c': { isDirty: true }, + }; + const depsByPackage = { + 'pkg-a': { 'pkg-b': 'workspace:*' }, + 'pkg-b': { 'pkg-c': 'workspace:*' }, + 'pkg-c': {}, + }; + propagateDirtyTransitively(packageMap, depsByPackage); + assert.ok(packageMap['pkg-b'].isDirty, 'pkg-b should be dirty (depends on dirty pkg-c)'); + assert.ok(packageMap['pkg-a'].isDirty, 'pkg-a should be dirty (depends on dirty pkg-b)'); + }); + + it('does not mark packages dirty when their deps are external (not in packageMap)', () => { + const packageMap = { 'pkg-a': { isDirty: false } }; + const depsByPackage = { 'pkg-a': { lodash: '^4' } }; + propagateDirtyTransitively(packageMap, depsByPackage); + assert.ok(!packageMap['pkg-a'].isDirty); + }); + + it('handles diamond dependency graphs without infinite loops', () => { + // pkg-a depends on pkg-b and pkg-c; both depend on pkg-d (dirty) + const packageMap = { + 'pkg-a': { isDirty: false }, + 'pkg-b': { isDirty: false }, + 'pkg-c': { isDirty: false }, + 'pkg-d': { isDirty: true }, + }; + const depsByPackage = { + 'pkg-a': { 'pkg-b': 'workspace:*', 'pkg-c': 'workspace:*' }, + 'pkg-b': { 'pkg-d': 'workspace:*' }, + 'pkg-c': { 'pkg-d': 'workspace:*' }, + 'pkg-d': {}, + }; + propagateDirtyTransitively(packageMap, depsByPackage); + assert.ok(packageMap['pkg-b'].isDirty); + assert.ok(packageMap['pkg-c'].isDirty); + assert.ok(packageMap['pkg-a'].isDirty); + }); +}); + +describe('computeNewVersion', () => { + it('increments patch version', () => { + assert.equal(computeNewVersion('1.2.3', 'patch'), '1.2.4'); + }); + + it('increments minor version (resets patch)', () => { + assert.equal(computeNewVersion('1.2.3', 'minor'), '1.3.0'); + }); + + it('increments major version (resets minor and patch)', () => { + assert.equal(computeNewVersion('1.2.3', 'major'), '2.0.0'); + }); + + it('creates -exp.0 from a stable version for experimental', () => { + assert.equal(computeNewVersion('1.2.3', 'experimental'), '1.2.3-exp.0'); + }); + + it('increments exp minor for experimental when already an exp version', () => { + assert.equal(computeNewVersion('1.2.3-exp.0', 'experimental'), '1.2.3-exp.1'); + }); + + it('creates a premajor rc version from a stable version', () => { + assert.equal(computeNewVersion('1.2.3', 'premajor'), '2.0.0-rc.0'); + }); + + it('increments the rc prerelease number for premajor when already an rc version', () => { + assert.equal(computeNewVersion('2.0.0-rc.0', 'premajor'), '2.0.0-rc.1'); + }); + + it('increments rc correctly across multiple premajor calls', () => { + assert.equal(computeNewVersion('2.0.0-rc.4', 'premajor'), '2.0.0-rc.5'); + }); +});