274 lines
9 KiB
JavaScript
274 lines
9 KiB
JavaScript
// 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/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/app.config.ts', (text) =>
|
|
replaceExactlyOnce(text, /version: '[^']+',/, `version: '${metadata.version}',`, 'Expo 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>[^<]+<\/Version>/.test(text)) {
|
|
return text.replace(/<Version>[^<]+<\/Version>/, `<Version>${metadata.version}</Version>`)
|
|
}
|
|
return replaceExactlyOnce(
|
|
text,
|
|
/(<TargetFramework>[^<]+<\/TargetFramework>)/,
|
|
`$1\n <Version>${metadata.version}</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)
|
|
}
|