// Repository-wide product version SSOT synchronizer and release-tag gate. // // Usage: // node scripts/ci/sync-version.mjs --check // node scripts/ci/sync-version.mjs --write // node scripts/ci/sync-version.mjs --check --tag v1.1.0 import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const args = process.argv.slice(2) const write = args.includes('--write') const check = args.includes('--check') || !write if (write && args.includes('--check')) { fail('Choose exactly one mode: --check or --write.') } const explicitTagIndex = args.indexOf('--tag') if (explicitTagIndex !== -1 && !args[explicitTagIndex + 1]) { fail('--tag requires a value.') } const explicitTag = explicitTagIndex === -1 ? undefined : args[explicitTagIndex + 1] const legacyTag = args.find( (arg, index) => !arg.startsWith('--') && index !== explicitTagIndex + 1, ) const tag = explicitTag ?? legacyTag ?? process.env.CI_COMMIT_TAG ?? (process.env.GITHUB_REF_NAME?.startsWith('v') ? process.env.GITHUB_REF_NAME : undefined) const metadataPath = join(root, 'release', 'product-version.json') const metadata = readJson(metadataPath) assertExactKeys(metadata, [ 'schemaVersion', 'version', 'androidVersionCode', 'iosBuildNumber', 'releaseDate', 'desktopLicensePublicKeyId', ]) if (metadata.schemaVersion !== 1) fail('Unsupported product-version schemaVersion.') if (!isStableSemver(metadata.version)) fail(`Invalid stable version: ${metadata.version}`) if ( !Number.isSafeInteger(metadata.androidVersionCode) || metadata.androidVersionCode < 1 || metadata.androidVersionCode > 2_100_000_000 ) { fail(`Invalid Android versionCode: ${metadata.androidVersionCode}`) } if (!Number.isSafeInteger(metadata.iosBuildNumber) || metadata.iosBuildNumber < 1) { fail(`Invalid iOS build number: ${metadata.iosBuildNumber}`) } if (!/^\d{4}-\d{2}-\d{2}$/.test(metadata.releaseDate)) { fail(`Invalid releaseDate: ${metadata.releaseDate}`) } if (!/^[0-9a-f]{64}$/.test(metadata.desktopLicensePublicKeyId)) { fail(`Invalid desktopLicensePublicKeyId: ${metadata.desktopLicensePublicKeyId}`) } if (tag !== undefined) { if (!/^v\d+\.\d+\.\d+$/.test(tag)) fail(`Release tag must be stable semver: ${tag}`) if (tag !== `v${metadata.version}`) { fail(`Release tag ${tag} does not match product version v${metadata.version}.`) } } const packageManifestPaths = [ 'package.json', 'apps/admin/package.json', 'apps/desktop/package.json', 'apps/mobile-rn/package.json', 'apps/web/package.json', 'packages/api-client/package.json', 'packages/core/package.json', 'packages/i18n/package.json', 'packages/ui/package.json', 'packages/ui-native/package.json', 'site/package.json', ] const changes = [] for (const path of packageManifestPaths) { updateJson(path, (document) => { document.version = metadata.version if (path === 'apps/desktop/package.json') { document.devDependencies.electron = '33.4.11' } }) } updateJson('package-lock.json', (document) => { document.version = metadata.version document.packages[''].version = metadata.version for (const path of packageManifestPaths.filter((path) => path !== 'site/package.json')) { const key = path === 'package.json' ? '' : path.replace(/\/package\.json$/, '') if (document.packages[key]) document.packages[key].version = metadata.version } if (document.packages['apps/desktop']?.devDependencies) { document.packages['apps/desktop'].devDependencies.electron = '33.4.11' } }) updateJson('apps/mobile-rn/package-lock.json', (document) => { document.version = metadata.version document.packages[''].version = metadata.version for (const key of [ '../..', '../../packages/api-client', '../../packages/core', '../../packages/i18n', '../../packages/ui-native', ]) { if (document.packages[key]) document.packages[key].version = metadata.version } }) if (existsSync(join(root, 'site', 'package-lock.json'))) { updateJson('site/package-lock.json', (document) => { document.version = metadata.version document.packages[''].version = metadata.version }) } updateText('apps/mobile-rn/android/app/build.gradle', (text) => { let next = replaceExactlyOnce( text, /def resolvedVersionName = versionSettingsValid \? configuredVersionName : "[^"]+"/, `def resolvedVersionName = versionSettingsValid ? configuredVersionName : "${metadata.version}"`, 'Android default versionName', ) next = replaceExactlyOnce( next, /def resolvedVersionCode = versionSettingsValid \? configuredVersionCodeValue\.toInteger\(\) : \d+/, `def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : ${metadata.androidVersionCode}`, 'Android default versionCode', ) return next }) updateText('apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj', (text) => { const currentCount = countMatches(text, /CURRENT_PROJECT_VERSION = \d+;/g) const marketingCount = countMatches(text, /MARKETING_VERSION = [^;]+;/g) if (currentCount !== 2 || marketingCount !== 2) { fail(`Unexpected iOS version surface count: build=${currentCount}, marketing=${marketingCount}`) } return text .replace(/CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${metadata.iosBuildNumber};`) .replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${metadata.version};`) }) updateText('apps/api-server/D3ROVoice.Api.csproj', (text) => { if (/[^<]+<\/Version>/.test(text)) { return text.replace(/[^<]+<\/Version>/, `${metadata.version}`) } return replaceExactlyOnce( text, /([^<]+<\/TargetFramework>)/, `$1\n ${metadata.version}`, '.NET product version anchor', ) }) updateJson('apps/admin-swagger/openapi.json', (document) => { document.info.version = metadata.version }) updateText('apps/web/src/components/layout/sidebar.tsx', (text) => replaceExactlyOnce( text, /\bv\d+\.\d+\.\d+\b/, `v${metadata.version}`, 'Web sidebar product version', ), ) const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8') if (!new RegExp(`^## \\[${escapeRegExp(metadata.version)}\\] - ${metadata.releaseDate}$`, 'm').test(changelog)) { fail(`CHANGELOG.md is missing [${metadata.version}] - ${metadata.releaseDate}.`) } if (changes.length === 0) { process.stdout.write( `[version] GREEN ${metadata.version} (${metadata.androidVersionCode}/${metadata.iosBuildNumber})\n`, ) process.exit(0) } if (check) { for (const change of changes) process.stderr.write(`[version] out of sync: ${change.path}\n`) fail('Run `npm run version:sync` and commit every synchronized surface.') } for (const change of changes) { writeFileSync(change.absolutePath, change.content, 'utf8') process.stdout.write(`[version] synchronized ${change.path}\n`) } process.stdout.write(`[version] synchronized product version ${metadata.version}\n`) function updateJson(path, mutate) { const absolutePath = join(root, path) const current = readFileSync(absolutePath, 'utf8') const document = JSON.parse(current) mutate(document) const next = `${JSON.stringify(document, null, 2)}\n` recordChange(path, absolutePath, current, next) } function updateText(path, mutate) { const absolutePath = join(root, path) const current = readFileSync(absolutePath, 'utf8') const next = mutate(current) recordChange(path, absolutePath, current, next) } function recordChange(path, absolutePath, current, content) { if (content !== current) changes.push({ path, absolutePath, content }) } function readJson(path) { try { return JSON.parse(readFileSync(path, 'utf8')) } catch (error) { fail( `Cannot parse ${relative(root, path)}: ${error instanceof Error ? error.message : String(error)}`, ) } } function assertExactKeys(value, expected) { const actual = Object.keys(value).sort() const wanted = [...expected].sort() if (JSON.stringify(actual) !== JSON.stringify(wanted)) { fail(`product-version keys must be exactly: ${wanted.join(', ')}`) } } function replaceExactlyOnce(text, pattern, replacement, label) { const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g` const matches = text.match(new RegExp(pattern.source, flags)) if (matches?.length !== 1) { fail(`${label} must match exactly once; found ${matches?.length ?? 0}.`) } return text.replace(pattern, replacement) } function countMatches(text, pattern) { return [...text.matchAll(pattern)].length } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function isStableSemver(value) { return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) } function fail(message) { process.stderr.write(`[version] ${message}\n`) process.exit(1) }