245 lines
10 KiB
JavaScript
245 lines
10 KiB
JavaScript
import { existsSync, readFileSync } from 'node:fs'
|
|
import { createHash, createPublicKey } from 'node:crypto'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
const CANONICAL_UPDATE_FEED =
|
|
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'
|
|
|
|
function read(path) {
|
|
return readFileSync(join(root, path), 'utf8')
|
|
}
|
|
|
|
function loadSurfaces(readSurface = read) {
|
|
return {
|
|
metadata: JSON.parse(readSurface('release/product-version.json')),
|
|
androidIdentity: JSON.parse(readSurface('release/android-release-identity.json')),
|
|
releaseEvidencePublicKey: readSurface('release/mobile-release-evidence-public.pem'),
|
|
desktopLicensePublicKey: readSurface('apps/desktop/resources/license/production-public.pem'),
|
|
rootPackage: JSON.parse(readSurface('package.json')),
|
|
desktopPackage: JSON.parse(readSurface('apps/desktop/package.json')),
|
|
rootLock: JSON.parse(readSurface('package-lock.json')),
|
|
builder: readSurface('apps/desktop/electron-builder.yml'),
|
|
electronVite: readSurface('apps/desktop/electron.vite.config.ts'),
|
|
updateFeed: readSurface('apps/desktop/src/main/update-feed.ts'),
|
|
publisher: readSurface('scripts/ci/publish-gitlab-release.mjs'),
|
|
gitlab: readSurface('.gitlab-ci.yml'),
|
|
github: readSurface('.github/workflows/release.yml'),
|
|
githubMac: readSurface('.github/workflows/build-mac.yml'),
|
|
githubSigning: readSurface('.github/workflows/release-signing-ca.yml'),
|
|
forgejoLinux: readSurface('.forgejo/workflows/deploy-site.yml'),
|
|
forgejoWindows: readSurface('.forgejo/workflows/deploy-site-windows.yml'),
|
|
changelog: readSurface('CHANGELOG.md'),
|
|
}
|
|
}
|
|
|
|
function validate(surfaces) {
|
|
const errors = []
|
|
const { metadata } = surfaces
|
|
const fail = (condition, code) => {
|
|
if (!condition) errors.push(code)
|
|
}
|
|
|
|
fail(/^\d+\.\d+\.\d+$/.test(metadata.version), 'metadata_version_not_stable_semver')
|
|
fail(surfaces.androidIdentity.packageName === 'com.d3ro.voice', 'android_package_identity_drift')
|
|
fail(/^\d+$/.test(surfaces.androidIdentity.playConsoleAppId), 'play_console_app_id_invalid')
|
|
fail(
|
|
/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/.test(surfaces.androidIdentity.playAppSigningCertificateSha256),
|
|
'play_app_signing_certificate_invalid',
|
|
)
|
|
fail(
|
|
/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/.test(surfaces.androidIdentity.uploadCertificateSha256),
|
|
'upload_certificate_invalid',
|
|
)
|
|
fail(
|
|
surfaces.androidIdentity.playAppSigningCertificateSha256 !== surfaces.androidIdentity.uploadCertificateSha256,
|
|
'play_and_upload_certificates_equal',
|
|
)
|
|
let evidenceKeyId = null
|
|
try {
|
|
const key = createPublicKey(surfaces.releaseEvidencePublicKey)
|
|
fail(key.asymmetricKeyType === 'ed25519', 'release_evidence_public_key_not_ed25519')
|
|
evidenceKeyId = createHash('sha256')
|
|
.update(key.export({ type: 'spki', format: 'der' }))
|
|
.digest('hex')
|
|
} catch {
|
|
errors.push('release_evidence_public_key_invalid')
|
|
}
|
|
fail(evidenceKeyId === surfaces.androidIdentity.releaseEvidenceKeyId, 'release_evidence_key_id_drift')
|
|
let desktopLicenseKeyId = null
|
|
try {
|
|
const key = createPublicKey(surfaces.desktopLicensePublicKey)
|
|
fail(key.asymmetricKeyType === 'ed25519', 'desktop_license_public_key_not_ed25519')
|
|
desktopLicenseKeyId = createHash('sha256')
|
|
.update(key.export({ type: 'spki', format: 'der' }))
|
|
.digest('hex')
|
|
} catch {
|
|
errors.push('desktop_license_public_key_invalid')
|
|
}
|
|
fail(
|
|
desktopLicenseKeyId === metadata.desktopLicensePublicKeyId,
|
|
'desktop_license_public_key_id_drift',
|
|
)
|
|
fail(
|
|
surfaces.electronVite.includes("resources/license/production-public.pem"),
|
|
'desktop_license_public_key_build_input_missing',
|
|
)
|
|
fail(
|
|
surfaces.electronVite.includes("asymmetricKeyType !== 'ed25519'"),
|
|
'desktop_license_public_key_build_validation_missing',
|
|
)
|
|
fail(
|
|
/^ca-app-pub-\d{16}~\d{10}$/.test(surfaces.androidIdentity.adMobAppId),
|
|
'admob_app_id_invalid',
|
|
)
|
|
fail(
|
|
/^ca-app-pub-\d{16}\/\d{10}$/.test(surfaces.androidIdentity.adMobBannerUnitId),
|
|
'admob_banner_unit_id_invalid',
|
|
)
|
|
fail(
|
|
/^ca-app-pub-\d{16}\/\d{10}$/.test(surfaces.androidIdentity.adMobRewardedUnitId),
|
|
'admob_rewarded_unit_id_invalid',
|
|
)
|
|
const adMobPublisher = surfaces.androidIdentity.adMobAppId.match(/^ca-app-pub-(\d+)~/)?.[1]
|
|
fail(
|
|
surfaces.androidIdentity.adMobBannerUnitId.startsWith(`ca-app-pub-${adMobPublisher}/`)
|
|
&& surfaces.androidIdentity.adMobRewardedUnitId.startsWith(`ca-app-pub-${adMobPublisher}/`),
|
|
'admob_publisher_drift',
|
|
)
|
|
fail(surfaces.rootPackage.version === metadata.version, 'root_package_version_drift')
|
|
fail(surfaces.desktopPackage.version === metadata.version, 'desktop_package_version_drift')
|
|
fail(
|
|
new RegExp(`^## \\[${escapeRegExp(metadata.version)}\\] - ${escapeRegExp(metadata.releaseDate)}$`, 'm')
|
|
.test(surfaces.changelog),
|
|
'changelog_release_section_missing',
|
|
)
|
|
|
|
const electronVersion = surfaces.desktopPackage.devDependencies?.electron
|
|
const lockedElectron = surfaces.rootLock.packages?.['node_modules/electron']?.version
|
|
const builderElectron = surfaces.builder.match(/^electronVersion:\s*["']?([^"'\s]+)["']?$/m)?.[1]
|
|
fail(electronVersion === lockedElectron, 'electron_package_lock_drift')
|
|
fail(builderElectron === lockedElectron, 'electron_builder_lock_drift')
|
|
|
|
const sourceFeed = surfaces.updateFeed.match(/UPDATE_FEED_URL\s*=\s*\n?\s*['"]([^'"]+)['"]/)?.[1]
|
|
const builderFeed = surfaces.builder.match(/publish:\s*[\s\S]*?\n\s+url:\s*["']([^"']+)["']/)?.[1]
|
|
fail(sourceFeed === CANONICAL_UPDATE_FEED, 'desktop_runtime_update_feed_drift')
|
|
fail(builderFeed === CANONICAL_UPDATE_FEED, 'desktop_builder_update_feed_drift')
|
|
fail(!/\/releases\/\d+\.\d+\.\d+/.test(sourceFeed ?? ''), 'desktop_update_feed_version_pinned')
|
|
|
|
fail(surfaces.publisher.includes('const latestFiles = [...sortedFiles].sort'), 'publisher_asset_first_order_missing')
|
|
fail(!surfaces.publisher.includes('deletePackagesForVersion("latest")'), 'publisher_deletes_live_feed_first')
|
|
fail(surfaces.publisher.includes('verifyPublicLatestFile'), 'publisher_public_metadata_verification_missing')
|
|
fail(surfaces.publisher.includes('release/product-version.json'), 'publisher_product_version_gate_missing')
|
|
|
|
for (const [name, workflow] of [
|
|
['gitlab', surfaces.gitlab],
|
|
['github', surfaces.github],
|
|
]) {
|
|
fail(workflow.includes('sync-version.mjs'), `${name}_version_gate_missing`)
|
|
fail(workflow.includes('release/product-version.json'), `${name}_product_metadata_missing`)
|
|
fail(!workflow.includes('1000000 + CI_PIPELINE_IID'), `${name}_pipeline_counter_version_code`)
|
|
fail(!workflow.includes('1000000 + GITHUB_RUN_NUMBER'), `${name}_run_counter_version_code`)
|
|
}
|
|
|
|
fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubMac), 'legacy_mac_tag_trigger_enabled')
|
|
fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubSigning), 'legacy_signing_tag_trigger_enabled')
|
|
fail(!surfaces.forgejoLinux.includes('sync-and-publish-forgejo-release'), 'forgejo_linux_legacy_release_sync')
|
|
fail(!surfaces.forgejoWindows.includes('sync-and-publish-forgejo-release'), 'forgejo_windows_legacy_release_sync')
|
|
fail(!existsSync(join(root, 'apps/mobile-rn/src/lib/update-manager.ts')), 'unsafe_mobile_update_manager_present')
|
|
fail(
|
|
existsSync(join(root, `apps/mobile-rn/metadata/android/ko-KR/changelogs/${metadata.androidVersionCode}.txt`)),
|
|
'play_korean_whats_new_missing',
|
|
)
|
|
fail(
|
|
existsSync(join(root, `apps/mobile-rn/metadata/android/en-US/changelogs/${metadata.androidVersionCode}.txt`)),
|
|
'play_english_whats_new_missing',
|
|
)
|
|
|
|
if (errors.length > 0) throw new Error(`release_metadata_invalid:${errors.join(',')}`)
|
|
return {
|
|
ok: true,
|
|
version: metadata.version,
|
|
androidVersionCode: metadata.androidVersionCode,
|
|
iosBuildNumber: metadata.iosBuildNumber,
|
|
updateFeed: sourceFeed,
|
|
electronVersion: lockedElectron,
|
|
releaseEvidenceKeyId: evidenceKeyId,
|
|
desktopLicensePublicKeyId: desktopLicenseKeyId,
|
|
}
|
|
}
|
|
|
|
function expectRejected(surfaces, mutate, expectedCode) {
|
|
const candidate = structuredClone(surfaces)
|
|
mutate(candidate)
|
|
try {
|
|
validate(candidate)
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes(expectedCode)) return
|
|
throw error
|
|
}
|
|
throw new Error(`release_metadata_self_test_failed:${expectedCode}`)
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
const surfaces = loadSurfaces()
|
|
const result = validate(surfaces)
|
|
|
|
if (process.argv.includes('--self-test')) {
|
|
expectRejected(
|
|
surfaces,
|
|
(candidate) => {
|
|
candidate.updateFeed = candidate.updateFeed.replace(CANONICAL_UPDATE_FEED, 'https://example.invalid/releases/1.1.0')
|
|
},
|
|
'desktop_runtime_update_feed_drift',
|
|
)
|
|
expectRejected(
|
|
surfaces,
|
|
(candidate) => {
|
|
candidate.gitlab = `${candidate.gitlab}\nVERSION_CODE="$((1000000 + CI_PIPELINE_IID))"\n`
|
|
},
|
|
'gitlab_pipeline_counter_version_code',
|
|
)
|
|
expectRejected(
|
|
surfaces,
|
|
(candidate) => {
|
|
candidate.publisher = candidate.publisher.replace('const latestFiles = [...sortedFiles].sort', 'const latestFiles = sortedFiles.sort')
|
|
},
|
|
'publisher_asset_first_order_missing',
|
|
)
|
|
expectRejected(
|
|
surfaces,
|
|
(candidate) => {
|
|
candidate.desktopLicensePublicKey = 'not-a-public-key'
|
|
},
|
|
'desktop_license_public_key_invalid',
|
|
)
|
|
expectRejected(
|
|
surfaces,
|
|
(candidate) => {
|
|
candidate.metadata.desktopLicensePublicKeyId = '0'.repeat(64)
|
|
},
|
|
'desktop_license_public_key_id_drift',
|
|
)
|
|
let missingDesktopKeyRejected = false
|
|
try {
|
|
loadSurfaces((path) => {
|
|
if (path === 'apps/desktop/resources/license/production-public.pem') {
|
|
throw new Error('desktop_license_public_key_missing')
|
|
}
|
|
return read(path)
|
|
})
|
|
} catch (error) {
|
|
missingDesktopKeyRejected =
|
|
error instanceof Error && error.message.includes('desktop_license_public_key_missing')
|
|
}
|
|
if (!missingDesktopKeyRejected) {
|
|
throw new Error('release_metadata_self_test_failed:desktop_license_public_key_missing')
|
|
}
|
|
result.negativeCases = 6
|
|
}
|
|
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|