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)), '..', '..') // canonical updater feed: Forgejo Generic Package Registry const CANONICAL_UPDATE_FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest' // legacy mirror: GitLab Generic Registry (pre-Forgejo installs still poll this) const LEGACY_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')), updatePolicy: JSON.parse(readSurface('release/update-policy.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'), updatePolicySource: readSurface('apps/desktop/src/main/update-policy.ts'), updateService: readSurface('apps/desktop/src/main/services/UpdateService.ts'), publisher: readSurface('scripts/ci/publish-gitlab-release.mjs'), forgejoPublisher: readSurface('scripts/ci/publish-forgejo-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'), forgejoRelease: readSurface('.forgejo/workflows/release.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') // ── canonical feed contract: runtime == builder == Forgejo canonical ── const sourceFeed = surfaces.updateFeed.match(/UPDATE_FEED_URL\s*=\s*\n?\s*['"]([^'"]+)['"]/)?.[1] const legacyFeed = surfaces.updateFeed.match(/LEGACY_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(legacyFeed === LEGACY_UPDATE_FEED, 'desktop_legacy_mirror_feed_missing') // ── update policy SSOT ── const policy = surfaces.updatePolicy fail(policy?.schemaVersion === 1, 'update_policy_schema_invalid') fail( ['latest', 'beta', 'alpha'].every((channel) => typeof policy?.channels?.[channel]?.allowPrerelease === 'boolean'), 'update_policy_channels_missing', ) fail( ['latest', 'beta', 'alpha'].includes(policy?.defaultChannel), 'update_policy_default_channel_invalid', ) fail(/^\d+\.\d+\.\d+$/.test(policy?.minimumSupportedVersion ?? ''), 'update_policy_minimum_invalid') fail( policy?.forceInstallBelow === null || /^\d+\.\d+\.\d+$/.test(policy?.forceInstallBelow ?? ''), 'update_policy_force_install_invalid', ) fail(typeof policy?.fullInstallOnMajorChange === 'boolean', 'update_policy_major_policy_missing') fail( Number.isSafeInteger(policy?.fullInstallVersionGap) && policy.fullInstallVersionGap >= 0, 'update_policy_version_gap_invalid', ) fail( Number.isSafeInteger(policy?.stagingPercentage) && policy.stagingPercentage >= 0 && policy.stagingPercentage <= 100, 'update_policy_staging_invalid', ) fail(typeof policy?.killSwitch === 'boolean', 'update_policy_kill_switch_missing') fail( surfaces.updatePolicySource.includes('decideUpdate') && surfaces.updatePolicySource.includes('fullInstallVersionGap') && surfaces.updatePolicySource.includes('isWithinRollout'), 'update_policy_runtime_logic_missing', ) fail( surfaces.updateService.includes('decideUpdate') && surfaces.updateService.includes('isWithinRollout') && /\bkillSwitch\b/.test(surfaces.updateService), 'update_service_policy_enforcement_missing', ) fail( surfaces.updateService.includes('disableDifferentialDownload'), 'update_service_differential_control_missing', ) // ── legacy GitLab publisher (mirror) ── 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') // ── canonical Forgejo publisher contract ── fail(!/1\.0\.0/.test(surfaces.forgejoPublisher), 'forgejo_publisher_hardcoded_version') fail(surfaces.forgejoPublisher.includes('release/product-version.json'), 'forgejo_publisher_version_gate_missing') fail(surfaces.forgejoPublisher.includes('const latestOrder'), 'forgejo_publisher_asset_first_order_missing') fail(surfaces.forgejoPublisher.includes('validateUpdateMetadataReferences'), 'forgejo_publisher_metadata_reference_check_missing') fail(surfaces.forgejoPublisher.includes('verifyPublicFile'), 'forgejo_publisher_public_verification_missing') fail(surfaces.forgejoPublisher.includes('update-policy.json'), 'forgejo_publisher_policy_upload_missing') fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing') fail( surfaces.forgejoPublisher.includes('/api/packages/') && surfaces.forgejoPublisher.includes('generic'), 'forgejo_publisher_registry_path_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(workflow.includes('publish-forgejo-release.mjs'), `${name}_forgejo_publish_missing`) } fail(surfaces.forgejoRelease.includes('publish-forgejo-release.mjs'), 'forgejo_release_workflow_publish_missing') fail(/tags:/.test(surfaces.forgejoRelease), 'forgejo_release_workflow_tag_trigger_missing') fail(surfaces.forgejoRelease.includes('sync-version.mjs'), 'forgejo_release_workflow_version_gate_missing') 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, legacyUpdateFeed: legacyFeed, updateChannel: policy.defaultChannel, minimumSupportedVersion: policy.minimumSupportedVersion, 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.updateFeed = candidate.updateFeed.replace(LEGACY_UPDATE_FEED, 'https://example.invalid/legacy') }, 'desktop_legacy_mirror_feed_missing', ) expectRejected( surfaces, (candidate) => { candidate.updatePolicy.minimumSupportedVersion = 'not-semver' }, 'update_policy_minimum_invalid', ) expectRejected( surfaces, (candidate) => { candidate.updatePolicy.stagingPercentage = 140 }, 'update_policy_staging_invalid', ) expectRejected( surfaces, (candidate) => { candidate.forgejoPublisher = 'console.log("1.0.0 is hardcoded")' }, 'forgejo_publisher_hardcoded_version', ) expectRejected( surfaces, (candidate) => { candidate.forgejoPublisher = candidate.forgejoPublisher.replace('const latestOrder', 'const uploadOrder') }, 'forgejo_publisher_asset_first_order_missing', ) expectRejected( surfaces, (candidate) => { candidate.gitlab = candidate.gitlab.replace('publish-forgejo-release.mjs', 'publish-gitlab-release.mjs') }, 'gitlab_forgejo_publish_missing', ) expectRejected( surfaces, (candidate) => { candidate.forgejoRelease = candidate.forgejoRelease.replace('tags:', 'branches:') }, 'forgejo_release_workflow_tag_trigger_missing', ) 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', ) expectRejected( surfaces, (candidate) => { candidate.updateService = candidate.updateService.replaceAll('killSwitch', 'killSwitchDisabled') }, 'update_service_policy_enforcement_missing', ) 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 = 13 } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)