d3ro-voice/scripts/ci/verify-release-metadata.mjs
Yun Chan f505a03d34
Some checks are pending
ci / 정본·보안·린트·타입·테스트 (push) Waiting to run
ci / 워크스페이스 빌드 검증 (push) Blocked by required conditions
ci / 모바일 린트·타입·Jest (push) Waiting to run
ci / Supabase Edge Functions + Cloudflare Worker (push) Waiting to run
ci / .NET API 서버 테스트 (push) Waiting to run
deploy-site / deploy (push) Waiting to run
fix(ci): point release-metadata checks at the relocated update and publish guards
CI run 121 failed at "버전·계약 정본 대조": earlier refactors moved update
gating from UpdateService into update-policy.ts (evaluateUpdateOffer ties
decideUpdate + isWithinRollout), the differential-download switch into
update-adapters.ts, and the Forgejo re-release guard into
lib/immutable-package-guard.mjs (sha256 comparison, abort on conflict).
The checks still looked for the old markers in the old files. They now
verify the same guarantees where the code lives, and the self-test's
negative case targets assertNoImmutableConflicts.

Also clears the lint gate: a control-character regex in Keycap (range now
starts at U+0020) and unused helpers in two red-team tests.
2026-09-28 20:55:11 +09:00

391 lines
17 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)), '..', '..')
// 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'),
updateAdapters: readSurface('apps/desktop/src/main/services/update-adapters.ts'),
publisher: readSurface('scripts/ci/publish-gitlab-release.mjs'),
forgejoPublisher: readSurface('scripts/ci/publish-forgejo-release.mjs'),
gitlab: readSurface('.gitlab-ci.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',
)
// 정책 판단(decideUpdate + isWithinRollout)은 update-policy.ts 의 evaluateUpdateOffer 가 묶는다.
// UpdateService 는 그 한 함수로 게이팅하고 킬 스위치를 직접 본다(2026-09 리팩터로 판단이 서비스 밖으로 옮겨졌다).
fail(
surfaces.updatePolicySource.includes('export function evaluateUpdateOffer') &&
/evaluateUpdateOffer[\s\S]*decideUpdate\(/.test(surfaces.updatePolicySource) &&
/evaluateUpdateOffer[\s\S]*isWithinRollout\(/.test(surfaces.updatePolicySource) &&
surfaces.updateService.includes('evaluateUpdateOffer(') &&
/\bkillSwitch\b/.test(surfaces.updateService),
'update_service_policy_enforcement_missing',
)
// 차등(증분) 다운로드 제어는 UpdateService 가 위임하는 update-adapters.ts 의 전자 업데이터 어댑터에 있다
fail(
surfaces.updateAdapters.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')
// 재게시 방지: 버전별 불변 경로의 원격 sha256 과 로컬을 비교해 다르면 중단한다(lib/immutable-package-guard.mjs)
fail(
surfaces.forgejoPublisher.includes('classifyImmutableAssets') &&
surfaces.forgejoPublisher.includes('assertNoImmutableConflicts('),
'forgejo_publisher_rerelease_guard_missing',
)
fail(
surfaces.forgejoPublisher.includes('/api/packages/') &&
surfaces.forgejoPublisher.includes('generic'),
'forgejo_publisher_registry_path_missing',
)
for (const [name, workflow] of [
['gitlab', surfaces.gitlab],
['forgejo', surfaces.forgejoRelease],
]) {
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')
// CI 정본은 Forgejo 한 벌이다(REFACTOR_POLICY W3-7). GitHub 원격이 없어 .github 워크플로는 실행되지 않는다.
fail(!existsSync(join(root, '.github/workflows')), 'github_workflows_reintroduced')
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.forgejoPublisher = candidate.forgejoPublisher.replaceAll('assertNoImmutableConflicts(', 'uploadAnyway(')
},
'forgejo_publisher_rerelease_guard_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 = 14
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)