d3ro-voice/scripts/ci/sync-version.mjs
Yun Chan 7953706142 feat(release): publish desktop updates from a tag through one feed
Desktop clients had two competing update sources: the runtime pointed at a
legacy GitLab registry while the Forgejo packages were filled in by
hardcoded, version-pinned scripts. Operators could not tell which feed was
authoritative, and no release could be reproduced from a tag.

Auto-update now reads a single canonical Forgejo registry feed, updated by
a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and
GitHub CI alike. Channel, minimum supported version, forced install,
full-versus-delta thresholds, staged rollout, and a remote kill switch come
from one policy file the client fetches alongside the feed. Tag creation is
gated on a clean tree, matching version surfaces, and a changelog section.
2026-09-16 23:23:00 +09:00

269 lines
8.8 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-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>[^<]+<\/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)
}