feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,75 @@
[CmdletBinding()]
param(
[string]$TailnetHost = '100.116.83.60',
[ValidateRange(1, 65535)]
[int]$SshPort = 22
)
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$configuration = @{}
Get-Content (Join-Path $root '.env') | ForEach-Object {
if ($_ -match '^([A-Z0-9_]+)=(.*)$') {
$configuration[$matches[1]] = $matches[2].Trim('"')
}
}
$sshUser = [string]$configuration.DSM_SSH_USER
if ([string]::IsNullOrWhiteSpace($sshUser)) { throw 'DSM_SSH_USER is unavailable.' }
$databaseTemp = [IO.Path]::GetTempFileName()
try {
$encoded = & ssh `
-o BatchMode=yes `
-o ConnectTimeout=8 `
-p $SshPort `
"$sshUser@$TailnetHost" `
'docker exec d3ro_voice_api base64 -w 0 /app/data/d3ro_api.db'
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($encoded)) {
throw 'Unable to read the API database for aggregate audit.'
}
[IO.File]::WriteAllBytes($databaseTemp, [Convert]::FromBase64String($encoded))
$python = @'
import json
import sqlite3
import sys
path = sys.argv[1]
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
tables = [row[0] for row in connection.execute(
"select name from sqlite_master where type='table' order by name"
)]
result = {"tables": tables}
for table in tables:
if "stt" not in table.lower() and "endpoint" not in table.lower():
continue
columns = [row[1] for row in connection.execute(f'pragma table_info("{table}")')]
row = {
"columns": columns,
"total": connection.execute(f'select count(*) from "{table}"').fetchone()[0],
}
key_column = next((name for name in columns if name.lower() in ("apikey", "api_key")), None)
enabled_column = next((name for name in columns if name.lower() in ("isenabled", "is_enabled", "enabled")), None)
if key_column:
row["configured_keys"] = connection.execute(
f'select count(*) from "{table}" where length(trim(coalesce("{key_column}", \'\'))) > 0'
).fetchone()[0]
if enabled_column:
row["enabled_rows"] = connection.execute(
f'select count(*) from "{table}" where "{enabled_column}" = 1'
).fetchone()[0]
result[table] = row
print(json.dumps(result, sort_keys=True))
connection.close()
'@
& python -c $python $databaseTemp
if ($LASTEXITCODE -ne 0) { throw 'SQLite aggregate audit failed.' }
} finally {
$encoded = $null
$resolvedTemp = [IO.Path]::GetFullPath($databaseTemp)
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if (-not $resolvedTemp.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Refusing to delete a temporary database outside the system temp directory.'
}
if ([IO.File]::Exists($resolvedTemp)) { [IO.File]::Delete($resolvedTemp) }
}

View file

@ -0,0 +1,72 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$VersionName,
[Parameter(Mandatory = $true)]
[int64]$VersionCode,
[Parameter(Mandatory = $true)]
[string]$OutputApk
)
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$androidRoot = Join-Path $root 'apps\mobile-rn\android'
$status = & supabase status --workdir (Join-Path $root 'server') -o env 2>$null
$configuration = @{}
foreach ($line in $status) {
if ($line -match '^([A-Z_]+)="(.*)"$') { $configuration[$matches[1]] = $matches[2] }
}
$publishableKey = [string]$configuration.PUBLISHABLE_KEY
if ($publishableKey -notmatch '^sb_publishable_[A-Za-z0-9_-]{20,}$') {
throw 'The local Supabase publishable key is unavailable.'
}
if ($VersionName -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$') {
throw 'VersionName must be strict semver.'
}
if ($VersionCode -le 0 -or $VersionCode -gt 2100000000) {
throw 'VersionCode is outside the Android range.'
}
$env:JAVA_HOME = 'C:\Program Files\Eclipse Adoptium\jdk-17.0.14.7-hotspot'
$env:D3RO_VERSION_NAME = $VersionName
$env:D3RO_VERSION_CODE = $VersionCode.ToString()
$builtApk = Join-Path $androidRoot 'app\build\outputs\apk\e2e\app-e2e.apk'
$expectedOutputParent = [System.IO.Path]::GetFullPath((Join-Path $androidRoot 'app\build\outputs\apk\e2e'))
$resolvedOutputParent = [System.IO.Path]::GetFullPath((Split-Path $builtApk -Parent))
if ($resolvedOutputParent -ne $expectedOutputParent) {
throw 'Refusing to replace an APK outside the exact E2E build output directory.'
}
try {
# A previously restored public TEST-DEMO APK can have the same Gradle output
# path while containing different BuildConfig inputs. Removing this one
# reproducible build artifact forces packageE2e to materialize the local
# runtime override instead of trusting stale up-to-date metadata.
if (Test-Path -LiteralPath $builtApk) {
Remove-Item -LiteralPath $builtApk -Force
}
Push-Location $androidRoot
try {
$arguments = @(
':app:assembleE2e',
'-PreactNativeArchitectures=arm64-v8a,x86_64',
'-PD3RO_E2E_SUPABASE_URL=http://10.0.2.2:55321',
"-PD3RO_E2E_SUPABASE_ANON_KEY=$publishableKey",
'--no-daemon'
)
& .\gradlew.bat @arguments
if ($LASTEXITCODE -ne 0) { throw "Gradle failed with exit code $LASTEXITCODE" }
} finally {
Pop-Location
}
if (-not (Test-Path -LiteralPath $builtApk)) {
throw 'Gradle did not materialize the local E2E APK.'
}
Copy-Item -LiteralPath $builtApk -Destination $OutputApk -Force
$sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $builtApk).Hash
$outputHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $OutputApk).Hash
if ($sourceHash -ne $outputHash) { throw 'Local E2E APK copy hash mismatch.' }
Write-Output "local_e2e_apk=$OutputApk"
Write-Output "sha256=$outputHash"
} finally {
Remove-Item Env:D3RO_VERSION_NAME, Env:D3RO_VERSION_CODE -ErrorAction SilentlyContinue
}

View file

@ -0,0 +1,139 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
const candidateOutput = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
{ encoding: 'utf8' },
)
const supportedFile = /\.(?:bat|cjs|cs|gradle|html|js|json|mjs|pem|properties|ps1|sh|toml|ts|tsx|ya?ml)$/i
const excludedPath = /(?:^|\/)(?:bin|build|dist|node_modules|obj|playwright-report|scratch)(?:\/|$)|(?:^|\/)(?:__tests__|fixtures?|tests?)(?:\/|$)|\.test\.[cm]?[jt]sx?$|\.spec\.[cm]?[jt]sx?$|(?:^|\/)migrations(?:\/|$)|(?:^|\/)package-lock\.json$|(?:^|\/)deno\.lock$|\.example\.(?:txt|json|ya?ml)$/i
const rules = [
{
name: 'basic-auth-literal',
pattern: /Buffer\.from\(\s*(['"])[^\r\n'"]{1,100}:[^\r\n'"]{4,}\1/,
},
{
name: 'browser-credential-literal',
pathPattern: /^scripts\//,
pattern: /(?:page\.fill\([^\r\n,]*(?:password|user_name|email)[^\r\n,]*,|(?:password|username|email|login)\w*\.fill\()\s*(['"])(?!\$|replace|example|dummy|test|changeme|your_|android)(?:(?!\1).){2,}\1/i,
},
{
name: 'browser-operator-email-literal',
pathPattern: /^scripts\//,
pattern: /(?:email|user_name|username|login|operator)[^\r\n]{0,120}(['"])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\1/i,
},
{
name: 'keystore-password-literal',
pattern: /--(?:ks|key)-pass\s+pass:[^\s'"`]+|(['"])-(?:store|key)pass\1\s*,\s*(['"])(?:(?!\2).){4,}\2|-?(?:store|key)pass\s+[^\s$%"'`][^\s"'`]*/i,
},
{
name: 'google-oauth-client-secret',
pattern: /GOCSPX-[A-Za-z0-9_-]{20,}/,
},
{
name: 'google-oauth-client-id',
pattern: /[0-9]{6,}-[A-Za-z0-9_-]{8,}\.apps\.googleusercontent\.com/,
},
{
name: 'google-api-key',
pattern: /AIza[A-Za-z0-9_-]{20,}/,
},
{
name: 'private-key-material',
pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----\r?\n[A-Za-z0-9+/=\r\n]{40,}-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
},
{
name: 'legacy-license-prefix-fail-open',
pattern: /\.startsWith\(\s*(['"])D3RO-(?:PRO|PLUS|TEAM)-\1\s*\)\s*&&\s*[A-Za-z_$][\w$]*\.length\s*>=/,
},
{
name: 'known-live-token-prefix',
pattern: /(?:ghp_|github_pat_|glpat-|rk_live_|sk_live_)[A-Za-z0-9_-]{12,}/,
},
{
name: 'credential-in-url',
pattern: /https?:\/\/[^\s\/@:]+:[^\s\/@]+@/,
},
{
name: 'credential-assignment-literal',
pattern: /(?:password|passwd|client[_-]?secret|api[_-]?secret|service[_-]?key|jwt[_-]?(?:secret|key)|admin[_-]?(?:bootstrap[_-]?token|session[_-]?secret)|service[_-]?role[_-]?key)\s*[:=]\s*(['"])(?!\s*(?:\$|%[A-Z_][A-Z0-9_]*%|replace|example|dummy|test|ci[-_]|changeme|your_|android)\b)(?:(?!\1).){8,}\1/i,
},
]
function scanSource(path, source) {
return rules
.filter((rule) => !rule.pathPattern || rule.pathPattern.test(path))
.filter((rule) => rule.pattern.test(source))
.map((rule) => `${path}: ${rule.name}`)
}
if (process.argv.includes('--self-test')) {
const legacyLicensePrefix = ['D3RO', 'PRO', ''].join('-')
const unsafeCases = [
['scripts/login.mjs', ['await emailInput.', 'fill(', "'operator", '@', "example.com')"].join('')],
['scripts/login.mjs', `const secret = '${'GOCSPX-'}abcdefghijklmnopqrstuvwxyz'`],
['scripts/login.mjs', `const id = '1234567890-abcdefghijklmnop.${'apps.'}googleusercontent.com'`],
['scripts/release.mjs', `const token = '${'ghp_'}abcdefghijklmnopqrstuvwxyz'`],
['scripts/deploy.ps1', `$JWT_SECRET = '${'a-fixed-'}jwt-signing-secret-value'`],
['scripts/gen-keystore.js', `const args = ['-storepass', '${'fixed-keystore-password'}']`],
[
'release/evidence-private.pem',
['-----BEGIN ', 'PRIVATE KEY-----\n', 'A'.repeat(64), '\n-----END ', 'PRIVATE KEY-----'].join(''),
],
[
'packages/core/src/license.ts',
`if (trimmed.startsWith('${legacyLicensePrefix}') && trimmed.length >= 14) return { valid: true }`,
],
]
const safeCases = [
['scripts/login.mjs', `await emailInput.fill(requireEnvironment('D3RO_PORTAL_OPERATOR_EMAIL'))`],
['scripts/release.mjs', `const token = process.env.FORGEJO_TOKEN?.trim()`],
['scripts/deploy.sh', `JWT_SECRET="$JWT_SECRET"`],
['.github/workflows/ci.yml', `MOBILE_E2E_PASSWORD: \${{ secrets.MOBILE_E2E_PASSWORD }}`],
['.env.example', 'JWT_SECRET='],
[
'release/evidence-public.pem',
['-----BEGIN ', 'PUBLIC KEY-----\n', 'A'.repeat(64), '\n-----END ', 'PUBLIC KEY-----'].join(''),
],
]
const failures = []
for (const [path, source] of unsafeCases) {
if (scanSource(path, source).length === 0) failures.push(`missed unsafe fixture: ${path}`)
}
for (const [path, source] of safeCases) {
if (scanSource(path, source).length > 0) failures.push(`flagged safe fixture: ${path}`)
}
if (failures.length > 0) {
for (const failure of failures) console.error(failure)
process.exit(1)
}
console.log('Hard-coded credential scanner self-test passed.')
process.exit(0)
}
const findings = []
for (const path of candidateOutput.split('\0').filter(Boolean)) {
const normalized = path.replaceAll('\\', '/')
if ((!supportedFile.test(normalized) && normalized !== '.env.example') || excludedPath.test(normalized)) continue
let source
try {
source = readFileSync(path, 'utf8')
} catch {
continue
}
findings.push(...scanSource(normalized, source))
}
if (findings.length > 0) {
console.error('Potential hard-coded credentials were found:')
for (const finding of findings.sort()) console.error(`- ${finding}`)
process.exit(1)
}
console.log('No hard-coded credential patterns found in source-controlled runtime files.')

View file

@ -0,0 +1,55 @@
import { createHash, generateKeyPairSync, sign, verify } from 'node:crypto'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
const args = process.argv.slice(2)
const privateKeyPath = resolve(requiredOption('--private-key'))
const publicKeyPath = resolve(requiredOption('--public-key'))
if (privateKeyPath === publicKeyPath) fail('Private and public key paths must differ.')
mkdirSync(dirname(privateKeyPath), { recursive: true })
mkdirSync(dirname(publicKeyPath), { recursive: true })
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
const probe = Buffer.from('d3ro-desktop-license-keypair-v1', 'utf8')
const signature = sign(null, probe, privateKey)
if (!verify(null, probe, publicKey, signature)) fail('Generated keypair self-check failed.')
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' })
const publicPem = publicKey.export({ type: 'spki', format: 'pem' })
let privateCreated = false
try {
writeFileSync(privateKeyPath, privatePem, { flag: 'wx', mode: 0o600 })
privateCreated = true
writeFileSync(publicKeyPath, publicPem, { flag: 'wx', mode: 0o644 })
} catch (error) {
if (privateCreated) rmSync(privateKeyPath, { force: true })
throw error
}
const keyId = createHash('sha256')
.update(publicKey.export({ type: 'spki', format: 'der' }))
.digest('hex')
process.stdout.write(`${JSON.stringify({
ok: true,
purpose: 'desktop-license-signing',
algorithm: 'Ed25519',
keyId,
privateKeyPath,
publicKeyPath,
}, null, 2)}\n`)
function requiredOption(name) {
const index = args.indexOf(name)
const value = index === -1 ? undefined : args[index + 1]
if (!value || value.startsWith('--')) fail(`${name} is required.`)
return value
}
function fail(message) {
process.stderr.write(`[desktop-license-keypair] ${message}\n`)
process.exit(1)
}

View file

@ -0,0 +1,145 @@
import { spawnSync } from 'node:child_process'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
buildReleasePayload,
createImmutableVerificationSnapshot,
hashRegularFileStable,
readSmallFileStable,
releaseBoundaryFail,
signReleaseEvidence,
writeJsonCreateOnly,
} from './mobile-release-evidence-lib.mjs'
function parseArguments(argv) {
const allowed = new Set([
'aab',
'apk',
'bundletool',
'commit-sha',
'expected-admob-app-id',
'expected-upload-cert-sha256',
'expected-version-code',
'expected-version-name',
'git-ref',
'private-key',
'repository',
'run-attempt',
'run-id',
'runner-identity',
'snapshot-dir',
'tree-sha',
'workflow-identity',
])
const result = {}
for (let index = 0; index < argv.length; index += 2) {
const flag = argv[index]
const value = argv[index + 1]
if (!flag?.startsWith('--') || !value || value.startsWith('--')) {
releaseBoundaryFail('create_evidence_arguments_invalid')
}
const name = flag.slice(2)
if (!allowed.has(name) || Object.hasOwn(result, name)) {
releaseBoundaryFail(`create_evidence_argument_rejected_${name}`)
}
result[name] = value
}
for (const name of allowed) {
if (!result[name]) releaseBoundaryFail(`create_evidence_argument_missing_${name}`)
}
return result
}
const options = parseArguments(process.argv.slice(2))
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const verifier = resolve(scriptDirectory, 'verify-android-artifact.mjs')
const bundletool = resolve(options.bundletool)
const snapshot = createImmutableVerificationSnapshot({
apkPath: resolve(options.apk),
aabPath: resolve(options.aab),
destinationDirectory: resolve(options['snapshot-dir']),
verifierPath: verifier,
bundletoolPath: bundletool,
})
const verifierBefore = hashRegularFileStable(snapshot.verifierPath)
const bundletoolBefore = hashRegularFileStable(snapshot.bundletoolPath)
const verifierArguments = [
snapshot.verifierPath,
'--mode', 'release',
'--apk', snapshot.apkPath,
'--aab', snapshot.aabPath,
'--bundletool', snapshot.bundletoolPath,
'--expected-admob-app-id', options['expected-admob-app-id'],
'--expected-upload-cert-sha256', options['expected-upload-cert-sha256'],
'--expected-version-name', options['expected-version-name'],
'--expected-version-code', options['expected-version-code'],
]
const verificationRun = spawnSync(process.execPath, verifierArguments, {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
})
if (verificationRun.status !== 0) {
const detail = String(verificationRun.stderr || verificationRun.stdout)
.trim()
.replace(/\s+/g, '_')
.slice(0, 500)
releaseBoundaryFail(`release_artifact_verifier_failed_${detail}`)
}
const verifierAfter = hashRegularFileStable(snapshot.verifierPath)
const bundletoolAfter = hashRegularFileStable(snapshot.bundletoolPath)
if (verifierBefore.sha256 !== verifierAfter.sha256 || verifierBefore.bytes !== verifierAfter.bytes) {
releaseBoundaryFail('verifier_changed_during_verification')
}
if (bundletoolBefore.sha256 !== bundletoolAfter.sha256 || bundletoolBefore.bytes !== bundletoolAfter.bytes) {
releaseBoundaryFail('bundletool_changed_during_verification')
}
let verification
try {
verification = JSON.parse(verificationRun.stdout)
} catch {
releaseBoundaryFail('release_artifact_verifier_json_invalid')
}
const expected = {
versionName: options['expected-version-name'],
versionCode: options['expected-version-code'],
adMobAppId: options['expected-admob-app-id'],
signerSha256: options['expected-upload-cert-sha256'],
}
const provenance = {
repository: options.repository,
commitSha: options['commit-sha'],
treeSha: options['tree-sha'],
gitRef: options['git-ref'],
workflowIdentity: options['workflow-identity'],
runId: options['run-id'],
runAttempt: options['run-attempt'],
runnerIdentity: options['runner-identity'],
verifierSha256: verifierBefore.sha256,
bundletoolSha256: bundletoolBefore.sha256,
}
const payload = buildReleasePayload({
verification,
apkPath: snapshot.apkPath,
aabPath: snapshot.aabPath,
expected,
provenance,
})
const privateKeyPem = readSmallFileStable(options['private-key'], 64 * 1024)
const evidence = signReleaseEvidence(payload, privateKeyPem)
// Both files are create-only. A rerun must start from a clean build output,
// never overwrite evidence that may already have been consumed downstream.
writeJsonCreateOnly(join(snapshot.destination, 'release-artifact-verification.json'), verification)
writeJsonCreateOnly(join(snapshot.destination, 'release-artifact-evidence.json'), evidence)
process.stdout.write(`${JSON.stringify({
ok: true,
mode: payload.mode,
packageName: payload.packageName,
versionName: payload.versionName,
versionCode: payload.versionCode,
apkSha256: payload.apk.sha256,
aabSha256: payload.aab.sha256,
evidenceKeyId: evidence.signature.keyId,
provenance: payload.provenance,
})}\n`)

View file

@ -0,0 +1,50 @@
import { createHash, generateKeyPairSync } from 'node:crypto'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
const args = process.argv.slice(2)
const privateKeyPath = resolve(option('--private-key'))
const publicKeyPath = resolve(option('--public-key'))
if (privateKeyPath === publicKeyPath) fail('Private and public key paths must differ.')
mkdirSync(dirname(privateKeyPath), { recursive: true })
mkdirSync(dirname(publicKeyPath), { recursive: true })
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' })
const publicPem = publicKey.export({ type: 'spki', format: 'pem' })
let privateCreated = false
try {
writeFileSync(privateKeyPath, privatePem, { flag: 'wx', mode: 0o600 })
privateCreated = true
writeFileSync(publicKeyPath, publicPem, { flag: 'wx', mode: 0o644 })
} catch (error) {
if (privateCreated) rmSync(privateKeyPath, { force: true })
throw error
}
const keyId = createHash('sha256')
.update(publicKey.export({ type: 'spki', format: 'der' }))
.digest('hex')
process.stdout.write(`${JSON.stringify({
ok: true,
algorithm: 'Ed25519',
keyId,
privateKeyPath,
publicKeyPath,
}, null, 2)}\n`)
function option(name) {
const index = args.indexOf(name)
const value = index === -1 ? undefined : args[index + 1]
if (!value || value.startsWith('--')) fail(`${name} is required.`)
return value
}
function fail(message) {
process.stderr.write(`[release-evidence-key] ${message}\n`)
process.exit(1)
}

View file

@ -0,0 +1,40 @@
import { readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const args = process.argv.slice(2)
const version = option('--version')
const output = option('--output')
if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`Invalid stable version: ${version}`)
const changelog = await readFile(join(root, 'CHANGELOG.md'), 'utf8')
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const lines = changelog.split(/\r?\n/)
const start = lines.findIndex((line) => new RegExp(`^## \\[${escaped}\\] - \\d{4}-\\d{2}-\\d{2}$`).test(line))
if (start === -1) fail(`CHANGELOG.md is missing the ${version} release section.`)
let end = lines.length
for (let index = start + 1; index < lines.length; index += 1) {
if (/^##\s+/.test(lines[index])) {
end = index
break
}
}
const notes = `${lines.slice(start, end).join('\n').trim()}\n`
await writeFile(join(root, output), notes, 'utf8')
process.stdout.write(`[release-notes] wrote ${output} for ${version}\n`)
function option(name) {
const index = args.indexOf(name)
const value = index === -1 ? undefined : args[index + 1]
if (!value || value.startsWith('--')) fail(`${name} is required.`)
return value
}
function fail(message) {
process.stderr.write(`[release-notes] ${message}\n`)
process.exit(1)
}

View file

@ -0,0 +1,737 @@
import {
createHash,
createPrivateKey,
createPublicKey,
randomBytes,
sign as signBytes,
verify as verifyBytes,
} from 'node:crypto'
import {
closeSync,
chmodSync,
constants as fsConstants,
copyFileSync,
existsSync,
fstatSync,
fsyncSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
readSync,
realpathSync,
rmdirSync,
unlinkSync,
writeFileSync,
writeSync,
} from 'node:fs'
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
export const RELEASE_EVIDENCE_SCHEMA_VERSION = 2
export const RELEASE_EVIDENCE_KIND = 'd3ro-android-production-release'
export const RELEASE_PACKAGE_NAME = 'com.d3ro.voice'
export const RELEASE_APK_NAME = 'app-release.apk'
export const RELEASE_AAB_NAME = 'app-release.aab'
export const SIGNED_EVIDENCE_NAME = 'release-artifact-evidence.json'
export const TEST_ADMOB_APP_ID = 'ca-app-pub-3940256099942544~3347511713'
export const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481'
const MAX_EVIDENCE_BYTES = 1024 * 1024
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
const DEBUG_SIGNER_SHA256 = new Set([
'fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c',
])
const HEX_SHA256 = /^[0-9a-f]{64}$/
const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
const REPOSITORY_IDENTITY = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/
const DECIMAL_ID = /^[1-9][0-9]{0,39}$/
const VERSION_NAME = /^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/
const PRODUCTION_ADMOB_APP_ID = /^ca-app-pub-\d+~\d+$/
const RELEASE_PROVENANCE_KEYS = [
'bundletoolSha256',
'commitSha',
'gitRef',
'repository',
'runAttempt',
'runId',
'runnerIdentity',
'treeSha',
'verifierSha256',
'workflowIdentity',
]
export function releaseBoundaryFail(code) {
throw new Error(`mobile_release_boundary:${code}`)
}
function isPlainObject(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
function requireExactKeys(value, expected, label) {
if (!isPlainObject(value)) releaseBoundaryFail(`${label}_must_be_object`)
const actual = Object.keys(value).sort()
const wanted = [...expected].sort()
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
releaseBoundaryFail(`${label}_keys_invalid`)
}
}
function canonicalValue(value) {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
if (typeof value === 'number') {
if (!Number.isFinite(value)) releaseBoundaryFail('canonical_number_invalid')
return value
}
if (Array.isArray(value)) return value.map(canonicalValue)
if (!isPlainObject(value)) releaseBoundaryFail('canonical_value_invalid')
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]),
)
}
export function canonicalJson(value) {
return JSON.stringify(canonicalValue(value))
}
export function sha256Buffer(buffer) {
return createHash('sha256').update(buffer).digest('hex')
}
export function normalizeSha256(value, label) {
if (typeof value !== 'string') releaseBoundaryFail(`${label}_missing`)
const normalized = value.replaceAll(':', '').trim().toLowerCase()
if (!HEX_SHA256.test(normalized)) releaseBoundaryFail(`${label}_invalid`)
return normalized
}
function normalizeBoundedIdentity(value, label, maximumLength = 256) {
if (typeof value !== 'string'
|| value.length === 0
|| value.length > maximumLength
|| value !== value.trim()
|| [...value].some((character) => {
const codePoint = character.codePointAt(0)
return codePoint <= 0x1f || codePoint === 0x7f
})) {
releaseBoundaryFail(`${label}_invalid`)
}
return value
}
function normalizeRepository(value, label) {
const normalized = normalizeBoundedIdentity(value, label)
if (!REPOSITORY_IDENTITY.test(normalized)
|| normalized.includes('//')
|| normalized.split('/').some((segment) => segment === '.' || segment === '..')) {
releaseBoundaryFail(`${label}_invalid`)
}
return normalized
}
function normalizeGitObjectId(value, label) {
if (typeof value !== 'string' || !GIT_OBJECT_ID.test(value)) {
releaseBoundaryFail(`${label}_invalid`)
}
return value
}
function normalizeGitRef(value, label) {
const normalized = normalizeBoundedIdentity(value, label)
if (!normalized.startsWith('refs/')
|| normalized.endsWith('/')
|| normalized.endsWith('.')
|| normalized.includes('..')
|| normalized.includes('//')
|| normalized.includes('@{')
|| normalized.includes('\\')
|| [...' ~^:?*[]'].some((character) => normalized.includes(character))
|| normalized.split('/').some((segment) => segment === '' || segment === '.' || segment === '..' || segment.endsWith('.lock'))) {
releaseBoundaryFail(`${label}_invalid`)
}
return normalized
}
function normalizeRunId(value, label) {
if (typeof value !== 'string' || !DECIMAL_ID.test(value)) {
releaseBoundaryFail(`${label}_invalid`)
}
return value
}
function normalizeRunAttempt(value, label) {
if (typeof value !== 'number' && (typeof value !== 'string' || !DECIMAL_ID.test(value))) {
releaseBoundaryFail(`${label}_invalid`)
}
const number = typeof value === 'number' ? value : Number(value)
if (!Number.isSafeInteger(number) || number <= 0 || number > 1_000_000) {
releaseBoundaryFail(`${label}_invalid`)
}
return number
}
export function validateReleaseProvenance(provenance, label = 'provenance') {
requireExactKeys(provenance, RELEASE_PROVENANCE_KEYS, label)
return {
repository: normalizeRepository(provenance.repository, `${label}_repository`),
commitSha: normalizeGitObjectId(provenance.commitSha, `${label}_commit_sha`),
treeSha: normalizeGitObjectId(provenance.treeSha, `${label}_tree_sha`),
gitRef: normalizeGitRef(provenance.gitRef, `${label}_git_ref`),
workflowIdentity: normalizeBoundedIdentity(
provenance.workflowIdentity,
`${label}_workflow_identity`,
),
runId: normalizeRunId(provenance.runId, `${label}_run_id`),
runAttempt: normalizeRunAttempt(provenance.runAttempt, `${label}_run_attempt`),
runnerIdentity: normalizeBoundedIdentity(
provenance.runnerIdentity,
`${label}_runner_identity`,
),
verifierSha256: normalizeSha256(
provenance.verifierSha256,
`${label}_verifier_sha256`,
),
bundletoolSha256: normalizeSha256(
provenance.bundletoolSha256,
`${label}_bundletool_sha256`,
),
}
}
function rejectCompromisedSigner(value, code) {
if (value === COMPROMISED_SIGNER_SHA256) releaseBoundaryFail(code)
return value
}
function stableStatValue(stat, key, fallbackKey) {
if (key in stat) return stat[key]
return BigInt(Math.trunc(Number(stat[fallbackKey]) * 1_000_000))
}
function sameFileSnapshot(left, right) {
// Windows lstat reports st_dev as zero while fstat reports the volume id.
// The stable inode plus size/timestamps remain comparable on that platform.
return (process.platform === 'win32' || left.dev === right.dev)
&& left.ino === right.ino
&& left.size === right.size
&& stableStatValue(left, 'mtimeNs', 'mtimeMs') === stableStatValue(right, 'mtimeNs', 'mtimeMs')
&& stableStatValue(left, 'ctimeNs', 'ctimeMs') === stableStatValue(right, 'ctimeNs', 'ctimeMs')
}
function sameFileIdentity(left, right) {
return (process.platform === 'win32' || left.dev === right.dev) && left.ino === right.ino
}
function openRegularFileNoFollow(filePath, maximumBytes = MAX_ARTIFACT_BYTES) {
const absolute = resolve(filePath)
const pathStat = lstatSync(absolute, { bigint: true })
if (pathStat.isSymbolicLink()) releaseBoundaryFail(`symlink_rejected_${basename(absolute)}`)
if (realpathSync(absolute) !== absolute) releaseBoundaryFail(`reparse_rejected_${basename(absolute)}`)
if (!pathStat.isFile()) releaseBoundaryFail(`regular_file_required_${basename(absolute)}`)
if (pathStat.nlink !== 1n) releaseBoundaryFail(`hardlink_rejected_${basename(absolute)}`)
if (pathStat.size <= 0n || pathStat.size > BigInt(maximumBytes)) {
releaseBoundaryFail(`file_size_invalid_${basename(absolute)}`)
}
const noFollow = fsConstants.O_NOFOLLOW ?? 0
const fd = openSync(absolute, fsConstants.O_RDONLY | noFollow)
const openedStat = fstatSync(fd, { bigint: true })
if (!openedStat.isFile() || !sameFileSnapshot(pathStat, openedStat)) {
closeSync(fd)
releaseBoundaryFail(`file_identity_changed_${basename(absolute)}`)
}
return { absolute, fd, openedStat }
}
export function readSmallFileStable(filePath, maximumBytes = MAX_EVIDENCE_BYTES) {
const opened = openRegularFileNoFollow(filePath, maximumBytes)
try {
const buffer = readFileSync(opened.fd)
const after = fstatSync(opened.fd, { bigint: true })
if (!sameFileSnapshot(opened.openedStat, after) || BigInt(buffer.length) !== after.size) {
releaseBoundaryFail(`file_changed_while_reading_${basename(opened.absolute)}`)
}
return buffer
} finally {
closeSync(opened.fd)
}
}
export function hashRegularFileStable(filePath) {
const opened = openRegularFileNoFollow(filePath)
try {
const hash = createHash('sha256')
const chunk = Buffer.allocUnsafe(1024 * 1024)
let total = 0n
for (;;) {
const count = readSync(opened.fd, chunk, 0, chunk.length, null)
if (count === 0) break
total += BigInt(count)
hash.update(chunk.subarray(0, count))
}
const after = fstatSync(opened.fd, { bigint: true })
if (!sameFileSnapshot(opened.openedStat, after) || total !== after.size) {
releaseBoundaryFail(`file_changed_while_hashing_${basename(opened.absolute)}`)
}
return { sha256: hash.digest('hex'), bytes: Number(total) }
} finally {
closeSync(opened.fd)
}
}
function assertPathWithinRoot(rootPath, candidatePath, expectedName) {
const unresolvedRoot = resolve(rootPath)
const unresolvedRootStat = lstatSync(unresolvedRoot)
if (!unresolvedRootStat.isDirectory() || unresolvedRootStat.isSymbolicLink()) {
releaseBoundaryFail('source_root_invalid')
}
const root = realpathSync(unresolvedRoot)
const rootStat = lstatSync(root)
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) releaseBoundaryFail('source_root_invalid')
const candidate = resolve(candidatePath)
if (basename(candidate) !== expectedName) releaseBoundaryFail(`${expectedName}_name_invalid`)
const relativePath = relative(root, candidate)
if (relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
releaseBoundaryFail(`${expectedName}_outside_source_root`)
}
const realCandidate = realpathSync(candidate)
const realRelative = relative(root, realCandidate)
if (realRelative === '' || realRelative === '..' || realRelative.startsWith(`..${sep}`) || isAbsolute(realRelative)) {
releaseBoundaryFail(`${expectedName}_realpath_outside_source_root`)
}
if (basename(realCandidate) !== expectedName) releaseBoundaryFail(`${expectedName}_real_name_invalid`)
return realCandidate
}
function normalizeVersionCode(value, label = 'version_code') {
const number = typeof value === 'number' ? value : Number(value)
if (!Number.isSafeInteger(number) || number <= 0 || number > 2_100_000_000) {
releaseBoundaryFail(`${label}_invalid`)
}
return number
}
function normalizeVersionName(value, label = 'version_name') {
if (typeof value !== 'string' || !VERSION_NAME.test(value)) {
releaseBoundaryFail(`${label}_invalid`)
}
return value
}
function normalizeProductionAdMobId(value) {
if (typeof value !== 'string' || !PRODUCTION_ADMOB_APP_ID.test(value)) {
releaseBoundaryFail('production_admob_app_id_invalid')
}
if (value === TEST_ADMOB_APP_ID || value.startsWith('ca-app-pub-3940256099942544')) {
releaseBoundaryFail('test_admob_rejected')
}
return value
}
function normalizeReleaseArtifact(value, expectedFileName, label) {
requireExactKeys(value, ['bytes', 'fileName', 'sha256'], label)
if (value.fileName !== expectedFileName || basename(value.fileName) !== value.fileName) {
releaseBoundaryFail(`${label}_file_name_invalid`)
}
const bytes = Number(value.bytes)
if (!Number.isSafeInteger(bytes) || bytes <= 0 || bytes > MAX_ARTIFACT_BYTES) {
releaseBoundaryFail(`${label}_bytes_invalid`)
}
return {
fileName: expectedFileName,
sha256: normalizeSha256(value.sha256, `${label}_sha256`),
bytes,
}
}
export function validateReleasePayload(payload) {
requireExactKeys(payload, [
'aab',
'adMobAppId',
'apk',
'debuggable',
'mode',
'packageName',
'provenance',
'signerSha256',
'verificationSha256',
'verifier',
'versionCode',
'versionName',
], 'payload')
if (payload.mode !== 'release') releaseBoundaryFail('nonrelease_mode_rejected')
if (payload.packageName !== RELEASE_PACKAGE_NAME) releaseBoundaryFail('package_name_mismatch')
if (payload.debuggable !== false) releaseBoundaryFail('debuggable_release_rejected')
if (payload.verifier !== 'scripts/ci/verify-android-artifact.mjs') {
releaseBoundaryFail('verifier_identity_invalid')
}
const signerSha256 = normalizeSha256(payload.signerSha256, 'signer_sha256')
if (DEBUG_SIGNER_SHA256.has(signerSha256)) releaseBoundaryFail('debug_signer_rejected')
rejectCompromisedSigner(signerSha256, 'compromised_signer_rejected')
return {
mode: 'release',
packageName: RELEASE_PACKAGE_NAME,
debuggable: false,
versionName: normalizeVersionName(payload.versionName),
versionCode: normalizeVersionCode(payload.versionCode),
adMobAppId: normalizeProductionAdMobId(payload.adMobAppId),
signerSha256,
provenance: validateReleaseProvenance(payload.provenance),
verifier: 'scripts/ci/verify-android-artifact.mjs',
verificationSha256: normalizeSha256(payload.verificationSha256, 'verification_sha256'),
apk: normalizeReleaseArtifact(payload.apk, RELEASE_APK_NAME, 'apk'),
aab: normalizeReleaseArtifact(payload.aab, RELEASE_AAB_NAME, 'aab'),
}
}
function requireReleaseVerification(verification, expected) {
if (!isPlainObject(verification)) releaseBoundaryFail('verification_must_be_object')
if (verification.mode !== 'release') releaseBoundaryFail('verification_nonrelease_mode')
if (verification.packageName !== RELEASE_PACKAGE_NAME) releaseBoundaryFail('verification_package_mismatch')
if (verification.debuggable !== false) releaseBoundaryFail('verification_debuggable_release')
const versionName = normalizeVersionName(verification.versionName, 'verification_version_name')
const versionCode = normalizeVersionCode(verification.versionCode, 'verification_version_code')
const adMobAppId = normalizeProductionAdMobId(verification.adMobAppId)
const signerSha256 = normalizeSha256(verification.signerSha256, 'verification_signer_sha256')
if (DEBUG_SIGNER_SHA256.has(signerSha256)) releaseBoundaryFail('verification_debug_signer')
rejectCompromisedSigner(signerSha256, 'verification_compromised_signer')
if (verification.artifact !== RELEASE_APK_NAME) releaseBoundaryFail('verification_apk_name')
if (!isPlainObject(verification.aab) || verification.aab.artifact !== RELEASE_AAB_NAME) {
releaseBoundaryFail('verification_aab_name')
}
if (verification.aab.packageName !== RELEASE_PACKAGE_NAME) {
releaseBoundaryFail('verification_aab_package_mismatch')
}
if (!Array.isArray(verification.abis)
|| verification.abis.length !== 1
|| verification.abis[0] !== 'arm64-v8a') {
releaseBoundaryFail('verification_apk_abis')
}
if (!Array.isArray(verification.aab.abis)
|| verification.aab.abis.length !== 1
|| verification.aab.abis[0] !== 'arm64-v8a') {
releaseBoundaryFail('verification_aab_abis')
}
const expectedVersionName = normalizeVersionName(expected.versionName, 'expected_version_name')
const expectedVersionCode = normalizeVersionCode(expected.versionCode, 'expected_version_code')
const expectedAdMobAppId = normalizeProductionAdMobId(expected.adMobAppId)
const expectedSigner = normalizeSha256(expected.signerSha256, 'expected_signer_sha256')
rejectCompromisedSigner(expectedSigner, 'expected_compromised_signer')
if (versionName !== expectedVersionName) releaseBoundaryFail('expected_version_name_mismatch')
if (versionCode !== expectedVersionCode) releaseBoundaryFail('expected_version_code_mismatch')
if (adMobAppId !== expectedAdMobAppId) releaseBoundaryFail('expected_admob_app_id_mismatch')
if (signerSha256 !== expectedSigner) releaseBoundaryFail('expected_signer_mismatch')
if (verification.aab.versionName !== versionName) releaseBoundaryFail('aab_apk_version_name_mismatch')
if (normalizeVersionCode(verification.aab.versionCode, 'aab_version_code') !== versionCode) {
releaseBoundaryFail('aab_apk_version_code_mismatch')
}
if (normalizeProductionAdMobId(verification.aab.adMobAppId) !== adMobAppId) {
releaseBoundaryFail('aab_apk_admob_app_id_mismatch')
}
if (verification.aab.debuggable !== false) releaseBoundaryFail('verification_aab_debuggable')
const aabSignerSha256 = normalizeSha256(verification.aab.signerSha256, 'aab_signer_sha256')
rejectCompromisedSigner(aabSignerSha256, 'verification_aab_compromised_signer')
if (aabSignerSha256 !== signerSha256) {
releaseBoundaryFail('aab_apk_signer_mismatch')
}
return { versionName, versionCode, adMobAppId, signerSha256 }
}
export function buildReleasePayload({ verification, apkPath, aabPath, expected, provenance }) {
const normalized = requireReleaseVerification(verification, expected)
const apk = hashRegularFileStable(apkPath)
const aab = hashRegularFileStable(aabPath)
if (normalizeSha256(verification.apkSha256, 'verification_apk_sha256') !== apk.sha256) {
releaseBoundaryFail('verification_apk_hash_mismatch')
}
if (normalizeSha256(verification.aab.sha256, 'verification_aab_sha256') !== aab.sha256) {
releaseBoundaryFail('verification_aab_hash_mismatch')
}
return validateReleasePayload({
mode: 'release',
packageName: RELEASE_PACKAGE_NAME,
debuggable: false,
versionName: normalized.versionName,
versionCode: normalized.versionCode,
adMobAppId: normalized.adMobAppId,
signerSha256: normalized.signerSha256,
provenance,
verifier: 'scripts/ci/verify-android-artifact.mjs',
verificationSha256: sha256Buffer(Buffer.from(canonicalJson(verification))),
apk: { fileName: RELEASE_APK_NAME, sha256: apk.sha256, bytes: apk.bytes },
aab: { fileName: RELEASE_AAB_NAME, sha256: aab.sha256, bytes: aab.bytes },
})
}
function publicKeyFingerprint(key) {
const publicKey = key?.type === 'public' ? key : createPublicKey(key)
if (publicKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_key_required')
return sha256Buffer(publicKey.export({ format: 'der', type: 'spki' }))
}
export function signReleaseEvidence(payload, privateKeyPem) {
const normalizedPayload = validateReleasePayload(payload)
const privateKey = createPrivateKey(privateKeyPem)
if (privateKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_private_key_required')
const unsigned = {
schemaVersion: RELEASE_EVIDENCE_SCHEMA_VERSION,
kind: RELEASE_EVIDENCE_KIND,
payload: normalizedPayload,
}
const signature = signBytes(null, Buffer.from(canonicalJson(unsigned)), privateKey)
return {
...unsigned,
signature: {
algorithm: 'Ed25519',
keyId: publicKeyFingerprint(privateKey),
value: signature.toString('base64'),
},
}
}
export function verifyReleaseEvidence(envelope, publicKeyPem) {
requireExactKeys(envelope, ['kind', 'payload', 'schemaVersion', 'signature'], 'evidence')
if (envelope.schemaVersion !== RELEASE_EVIDENCE_SCHEMA_VERSION) {
releaseBoundaryFail('evidence_schema_version')
}
if (envelope.kind !== RELEASE_EVIDENCE_KIND) releaseBoundaryFail('evidence_kind')
requireExactKeys(envelope.signature, ['algorithm', 'keyId', 'value'], 'signature')
if (envelope.signature.algorithm !== 'Ed25519') releaseBoundaryFail('signature_algorithm')
const publicKey = createPublicKey(publicKeyPem)
if (publicKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_public_key_required')
const expectedKeyId = publicKeyFingerprint(publicKey)
if (normalizeSha256(envelope.signature.keyId, 'signature_key_id') !== expectedKeyId) {
releaseBoundaryFail('signature_key_mismatch')
}
let signature
try {
signature = Buffer.from(envelope.signature.value, 'base64')
} catch {
releaseBoundaryFail('signature_encoding')
}
if (signature.length !== 64) releaseBoundaryFail('signature_length')
const unsigned = {
schemaVersion: envelope.schemaVersion,
kind: envelope.kind,
payload: envelope.payload,
}
if (!verifyBytes(null, Buffer.from(canonicalJson(unsigned)), publicKey, signature)) {
releaseBoundaryFail('signature_invalid')
}
return validateReleasePayload(envelope.payload)
}
export function verifyExpectedRelease(payload, expected) {
const normalized = validateReleasePayload(payload)
if (normalized.versionName !== normalizeVersionName(expected.versionName, 'expected_version_name')) {
releaseBoundaryFail('publication_version_name_mismatch')
}
if (normalized.versionCode !== normalizeVersionCode(expected.versionCode, 'expected_version_code')) {
releaseBoundaryFail('publication_version_code_mismatch')
}
if (normalized.adMobAppId !== normalizeProductionAdMobId(expected.adMobAppId)) {
releaseBoundaryFail('publication_admob_app_id_mismatch')
}
const expectedSigner = normalizeSha256(expected.signerSha256, 'expected_signer_sha256')
rejectCompromisedSigner(expectedSigner, 'publication_expected_compromised_signer')
if (normalized.signerSha256 !== expectedSigner) {
releaseBoundaryFail('publication_signer_mismatch')
}
const expectedProvenance = validateReleaseProvenance(
expected.provenance,
'expected_provenance',
)
for (const key of RELEASE_PROVENANCE_KEYS) {
if (normalized.provenance[key] !== expectedProvenance[key]) {
const code = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
releaseBoundaryFail(`publication_provenance_${code}_mismatch`)
}
}
return normalized
}
function snapshotArtifact(sourcePath, destinationDirectory, expected) {
const directoryIdentity = lstatSync(destinationDirectory, { bigint: true })
const opened = openRegularFileNoFollow(sourcePath)
const temporaryName = `.${expected.fileName}.${randomBytes(16).toString('hex')}.tmp`
const temporaryPath = join(destinationDirectory, temporaryName)
const destinationPath = join(destinationDirectory, expected.fileName)
let outputFd = -1
let completed = false
try {
outputFd = openSync(
temporaryPath,
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
0o600,
)
const hash = createHash('sha256')
const chunk = Buffer.allocUnsafe(1024 * 1024)
let total = 0n
for (;;) {
const count = readSync(opened.fd, chunk, 0, chunk.length, null)
if (count === 0) break
writeSync(outputFd, chunk, 0, count)
hash.update(chunk.subarray(0, count))
total += BigInt(count)
}
fsyncSync(outputFd)
closeSync(outputFd)
outputFd = -1
const sourceAfter = fstatSync(opened.fd, { bigint: true })
if (!sameFileSnapshot(opened.openedStat, sourceAfter) || total !== sourceAfter.size) {
releaseBoundaryFail(`source_changed_during_snapshot_${expected.fileName}`)
}
if (Number(total) !== expected.bytes) releaseBoundaryFail(`${expected.fileName}_size_mismatch`)
if (hash.digest('hex') !== expected.sha256) releaseBoundaryFail(`${expected.fileName}_hash_mismatch`)
copyFileSync(temporaryPath, destinationPath, fsConstants.COPYFILE_EXCL)
const directoryAfterCreate = lstatSync(destinationDirectory, { bigint: true })
if (!sameFileIdentity(directoryIdentity, directoryAfterCreate)
|| realpathSync(destinationDirectory) !== destinationDirectory) {
releaseBoundaryFail('destination_directory_replaced')
}
const copied = hashRegularFileStable(destinationPath)
if (copied.bytes !== expected.bytes || copied.sha256 !== expected.sha256) {
releaseBoundaryFail(`${expected.fileName}_sealed_copy_mismatch`)
}
chmodSync(destinationPath, 0o400)
completed = true
return destinationPath
} finally {
closeSync(opened.fd)
if (outputFd >= 0) closeSync(outputFd)
if (existsSync(temporaryPath)) unlinkSync(temporaryPath)
if (!completed && existsSync(destinationPath)) unlinkSync(destinationPath)
}
}
function safeCreateDestination(destinationDirectory) {
const destination = resolve(destinationDirectory)
const unresolvedParent = dirname(destination)
const parentStat = lstatSync(unresolvedParent, { bigint: true })
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
releaseBoundaryFail('destination_parent_invalid')
}
const parent = realpathSync(unresolvedParent)
if (destination === parent || basename(destination) === '' || basename(destination) === '.' || basename(destination) === '..') {
releaseBoundaryFail('destination_directory_too_broad')
}
if (existsSync(destination)) releaseBoundaryFail('destination_must_not_exist')
mkdirSync(destination, { recursive: false, mode: 0o700 })
const created = realpathSync(destination)
if (dirname(created) !== parent) releaseBoundaryFail('destination_parent_mismatch')
return created
}
export function createImmutableVerificationSnapshot({
apkPath,
aabPath,
destinationDirectory,
verifierPath,
bundletoolPath,
}) {
if (basename(resolve(apkPath)) !== RELEASE_APK_NAME) releaseBoundaryFail('snapshot_apk_name_invalid')
if (basename(resolve(aabPath)) !== RELEASE_AAB_NAME) releaseBoundaryFail('snapshot_aab_name_invalid')
const includesTools = verifierPath !== undefined || bundletoolPath !== undefined
if (includesTools && (verifierPath === undefined || bundletoolPath === undefined)) {
releaseBoundaryFail('snapshot_tool_pair_required')
}
const expectedApk = { fileName: RELEASE_APK_NAME, ...hashRegularFileStable(apkPath) }
const expectedAab = { fileName: RELEASE_AAB_NAME, ...hashRegularFileStable(aabPath) }
const expectedVerifier = includesTools
? { fileName: 'verify-android-artifact.mjs', ...hashRegularFileStable(verifierPath) }
: null
const expectedBundletool = includesTools
? { fileName: 'bundletool.jar', ...hashRegularFileStable(bundletoolPath) }
: null
const destination = safeCreateDestination(destinationDirectory)
const createdFiles = []
try {
const apk = snapshotArtifact(apkPath, destination, expectedApk)
createdFiles.push(apk)
const aab = snapshotArtifact(aabPath, destination, expectedAab)
createdFiles.push(aab)
if (!includesTools) return { destination, apkPath: apk, aabPath: aab }
const verifier = snapshotArtifact(verifierPath, destination, expectedVerifier)
createdFiles.push(verifier)
const bundletool = snapshotArtifact(bundletoolPath, destination, expectedBundletool)
createdFiles.push(bundletool)
return {
destination,
apkPath: apk,
aabPath: aab,
verifierPath: verifier,
bundletoolPath: bundletool,
}
} catch (error) {
cleanupCreatedDestination(destination, createdFiles)
throw error
}
}
function cleanupCreatedDestination(destination, createdFiles) {
for (const file of [...createdFiles].reverse()) {
if (existsSync(file) && dirname(file) === destination) unlinkSync(file)
}
if (existsSync(destination)) rmdirSync(destination)
}
export function prepareVerifiedReleasePublication({
sourceRoot,
apkPath,
aabPath,
evidencePath,
publicKeyPath,
destinationDirectory,
expected,
}) {
const apk = assertPathWithinRoot(sourceRoot, apkPath, RELEASE_APK_NAME)
const aab = assertPathWithinRoot(sourceRoot, aabPath, RELEASE_AAB_NAME)
const evidence = assertPathWithinRoot(sourceRoot, evidencePath, SIGNED_EVIDENCE_NAME)
const evidenceBuffer = readSmallFileStable(evidence)
let envelope
try {
envelope = JSON.parse(evidenceBuffer.toString('utf8'))
} catch {
releaseBoundaryFail('evidence_json_invalid')
}
const publicKeyPem = readSmallFileStable(publicKeyPath, 64 * 1024)
const payload = verifyExpectedRelease(
verifyReleaseEvidence(envelope, publicKeyPem),
expected,
)
const destination = safeCreateDestination(destinationDirectory)
const createdFiles = []
try {
createdFiles.push(snapshotArtifact(apk, destination, payload.apk))
createdFiles.push(snapshotArtifact(aab, destination, payload.aab))
const sealedEvidence = join(destination, 'android-release-evidence.json')
writeFileSync(sealedEvidence, evidenceBuffer, { flag: 'wx', mode: 0o600 })
createdFiles.push(sealedEvidence)
const manifest = {
schemaVersion: 2,
kind: 'd3ro-android-publication-set',
packageName: payload.packageName,
versionName: payload.versionName,
versionCode: payload.versionCode,
signerSha256: payload.signerSha256,
adMobAppId: payload.adMobAppId,
evidenceKeyId: envelope.signature.keyId,
provenance: payload.provenance,
artifacts: [payload.apk, payload.aab],
}
const manifestPath = join(destination, 'android-publication-manifest.json')
writeFileSync(manifestPath, `${canonicalJson(manifest)}\n`, { flag: 'wx', mode: 0o600 })
createdFiles.push(manifestPath)
return { destination, manifest, files: createdFiles.map((file) => basename(file)) }
} catch (error) {
cleanupCreatedDestination(destination, createdFiles)
throw error
}
}
export function writeJsonCreateOnly(filePath, value) {
const absolute = resolve(filePath)
if (!existsSync(dirname(absolute))) releaseBoundaryFail('output_parent_missing')
writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
}

View file

@ -0,0 +1,75 @@
import {
prepareVerifiedReleasePublication,
releaseBoundaryFail,
} from './mobile-release-evidence-lib.mjs'
function parseArguments(argv) {
const allowed = new Set([
'aab',
'apk',
'destination-dir',
'evidence',
'expected-admob-app-id',
'expected-bundletool-sha256',
'expected-commit-sha',
'expected-git-ref',
'expected-repository',
'expected-run-attempt',
'expected-run-id',
'expected-runner-identity',
'expected-tree-sha',
'expected-upload-cert-sha256',
'expected-verifier-sha256',
'expected-version-code',
'expected-version-name',
'expected-workflow-identity',
'public-key',
'source-root',
])
const result = {}
for (let index = 0; index < argv.length; index += 2) {
const flag = argv[index]
const value = argv[index + 1]
if (!flag?.startsWith('--') || !value || value.startsWith('--')) {
releaseBoundaryFail('prepare_publication_arguments_invalid')
}
const name = flag.slice(2)
if (!allowed.has(name) || Object.hasOwn(result, name)) {
releaseBoundaryFail(`prepare_publication_argument_rejected_${name}`)
}
result[name] = value
}
for (const name of allowed) {
if (!result[name]) releaseBoundaryFail(`prepare_publication_argument_missing_${name}`)
}
return result
}
const options = parseArguments(process.argv.slice(2))
const result = prepareVerifiedReleasePublication({
sourceRoot: options['source-root'],
apkPath: options.apk,
aabPath: options.aab,
evidencePath: options.evidence,
publicKeyPath: options['public-key'],
destinationDirectory: options['destination-dir'],
expected: {
versionName: options['expected-version-name'],
versionCode: options['expected-version-code'],
adMobAppId: options['expected-admob-app-id'],
signerSha256: options['expected-upload-cert-sha256'],
provenance: {
repository: options['expected-repository'],
commitSha: options['expected-commit-sha'],
treeSha: options['expected-tree-sha'],
gitRef: options['expected-git-ref'],
workflowIdentity: options['expected-workflow-identity'],
runId: options['expected-run-id'],
runAttempt: options['expected-run-attempt'],
runnerIdentity: options['expected-runner-identity'],
verifierSha256: options['expected-verifier-sha256'],
bundletoolSha256: options['expected-bundletool-sha256'],
},
},
})
process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`)

View file

@ -0,0 +1,72 @@
import { createHash } from 'node:crypto'
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
const MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin'
const MODEL_BYTES = 77_691_713
const MODEL_SHA256 = 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
const DEFAULT_OUTPUT = 'apps/mobile-rn/android/app/src/main/assets/models/ggml-tiny.bin'
async function digestFile(path) {
const hash = createHash('sha256')
await pipeline(createReadStream(path), hash)
return hash.digest('hex')
}
async function isVerifiedModel(path) {
try {
const metadata = await stat(path)
if (!metadata.isFile() || metadata.size !== MODEL_BYTES) return false
return (await digestFile(path)) === MODEL_SHA256
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
}
async function downloadVerifiedModel(outputPath) {
const temporaryPath = `${outputPath}.part-${process.pid}`
await mkdir(dirname(outputPath), { recursive: true })
try {
const response = await fetch(MODEL_URL, {
redirect: 'follow',
signal: AbortSignal.timeout(120_000)
})
if (!response.ok || !response.body) {
throw new Error(`whisper_model_download_failed:${response.status}`)
}
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength !== MODEL_BYTES) {
throw new Error(`whisper_model_length_mismatch:${declaredLength}`)
}
await pipeline(
Readable.fromWeb(response.body),
createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 })
)
if (!(await isVerifiedModel(temporaryPath))) {
throw new Error('whisper_model_checksum_mismatch')
}
await rm(outputPath, { force: true })
await rename(temporaryPath, outputPath)
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined)
throw error
}
}
const outputPath = resolve(process.argv[2] ?? DEFAULT_OUTPUT)
if (await isVerifiedModel(outputPath)) {
console.log(`Whisper model verified: ${outputPath}`)
} else {
await downloadVerifiedModel(outputPath)
console.log(`Whisper model downloaded and verified: ${outputPath}`)
}

View file

@ -10,8 +10,9 @@
//
// 필요 env: CI_API_V4_URL, CI_PROJECT_ID, CI_JOB_TOKEN, CI_COMMIT_TAG
import { createReadStream } from "node:fs";
import { createReadStream, readFileSync } from "node:fs";
import { readFile, readdir, stat } from "node:fs/promises";
import { timingSafeEqual } from "node:crypto";
import { basename, join } from "node:path";
import process from "node:process";
import { fileURLToPath, URL } from "node:url";
@ -27,6 +28,13 @@ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Unsupported release tag: ${tag}`);
}
const productVersion = JSON.parse(
await readFile(fileURLToPath(new URL("../../release/product-version.json", import.meta.url)), "utf8"),
);
if (tag !== `v${productVersion.version}` || !/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Release tag ${tag} does not match stable product version v${productVersion.version}.`);
}
// electron-builder output: apps/desktop/release/<version>/
const releaseDirectory = fileURLToPath(new URL(`../../apps/desktop/release/${version}/`, import.meta.url));
@ -79,41 +87,28 @@ for (const file of sortedFiles) {
});
}
// 2) latest 패키지 갱신 — 이전 latest 삭제 후 현재 버전 재업로드.
// 삭제는 best-effort — JOB-TOKEN에 패키지 삭제 권한이 없는 GitLab 설정에서도
// 릴리스가 막히지 않게 한다 (동일 파일명 재업로드 시 다운로드는 최신 파일 우선).
try {
await deletePackagesForVersion("latest");
} catch (err) {
process.stdout.write(
`WARNING: stale latest package cleanup failed (continuing): ${err instanceof Error ? err.message : String(err)}\n`,
);
}
for (const file of sortedFiles) {
// 2) latest 패키지 갱신. 설치파일과 blockmap을 먼저 올리고, update metadata를
// 마지막에 게시한다. 기존 latest를 선삭제하지 않으므로 배포 중에도 이전
// 설치본이 404를 받지 않는다. 중복 업로드가 금지된 인스턴스라면 metadata
// 전환 전에 실패해 기존 feed가 그대로 보존된다.
const latestFiles = [...sortedFiles].sort((a, b) => {
const metadataOrder = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name));
return metadataOrder || a.name.localeCompare(b.name);
});
for (const file of latestFiles) {
const registryName = safeAssetName(file.name);
await uploadFile(file, `${latestPackageBaseUrl}/${encodeURIComponent(registryName)}`, "latest package");
}
// 3) GitLab Release 생성
for (const file of latestFiles.filter((candidate) => isUpdateMetadata(candidate.name))) {
validateUpdateMetadataReferences(file, latestFiles);
await verifyPublicLatestFile(file, latestPackageBaseUrl);
}
// 3) GitLab Release 생성 또는 재시도 시 안전하게 갱신
const description = await buildDescription();
const releaseUrl = `${apiUrl}/projects/${encodeURIComponent(projectId)}/releases`;
const releaseResponse = await globalThis.fetch(releaseUrl, {
method: "POST",
headers: {
"JOB-TOKEN": jobToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `D3RO Voice ${tag}`,
tag_name: tag,
description,
assets: { links },
}),
});
if (!releaseResponse.ok) {
throw new Error(`Release creation failed: HTTP ${releaseResponse.status} ${await releaseResponse.text()}`);
}
await upsertRelease(releaseUrl, description, links);
process.stdout.write(`Created ${tag} with ${links.length} release assets.\n`);
@ -134,27 +129,13 @@ function isUpdateMetadata(name) {
}
async function buildDescription() {
// CHANGELOG.md의 해당 버전 섹션이 있으면 사용, 없으면 기본 안내문
try {
const changelog = await readFile(fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)), "utf8");
const section = extractChangelogSection(changelog, version);
if (section) return section;
} catch {
// CHANGELOG 없음 — 기본 안내문 사용
}
const lines = [
`## D3RO Voice ${tag}`,
"",
"아래 Assets에서 플랫폼별 설치 파일을 받으세요.",
"",
"- Windows: `D3RO Voice Setup *.exe`",
hasMac ? "- macOS (Apple Silicon): `*.dmg` — 무서명 빌드는 우클릭 → 열기로 실행" : null,
"",
"첫 실행 시 온보딩에서 AI 모델을 자동 다운로드합니다:",
"- LLM: `gemma4:e4b` (~9.6GB)",
"- Whisper: `large-v3-turbo` (~1.6GB)",
].filter((line) => line !== null);
return lines.join("\n");
const changelog = await readFile(
fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)),
"utf8",
);
const section = extractChangelogSection(changelog, version);
if (!section) throw new Error(`CHANGELOG.md is missing a ${version} release section.`);
return section;
}
function extractChangelogSection(changelog, targetVersion) {
@ -187,44 +168,86 @@ async function uploadFile(file, uploadUrl, target) {
}
}
async function listPackagesForVersion(packageVersion) {
const packages = [];
let page = "1";
while (page) {
const listUrl = new URL(`${apiUrl}/projects/${encodeURIComponent(projectId)}/packages`);
listUrl.searchParams.set("package_type", "generic");
listUrl.searchParams.set("package_name", packageName);
listUrl.searchParams.set("package_version", packageVersion);
listUrl.searchParams.set("per_page", "100");
listUrl.searchParams.set("page", page);
const response = await globalThis.fetch(listUrl, { headers: { "JOB-TOKEN": jobToken } });
if (!response.ok) {
throw new Error(`Package lookup failed for ${packageVersion}: HTTP ${response.status} ${await response.text()}`);
}
const rows = await response.json();
if (!Array.isArray(rows)) throw new Error(`Package lookup returned an invalid response for ${packageVersion}.`);
packages.push(
...rows.filter(
(row) => row?.package_type === "generic" && row?.name === packageName && row?.version === packageVersion,
),
);
page = response.headers.get("x-next-page")?.trim() ?? "";
function validateUpdateMetadataReferences(metadataFile, uploadedFiles) {
const text = readFileSyncUtf8(metadataFile.path);
const uploadedNames = new Set(uploadedFiles.map((file) => safeAssetName(file.name)));
const references = [...text.matchAll(/^\s*(?:-\s+url:|path:)\s*["']?([^"'\r\n]+?)["']?\s*$/gm)]
.map((match) => basename(match[1].trim()));
if (references.length === 0) {
throw new Error(`${metadataFile.name} does not reference a release artifact.`);
}
return packages;
}
async function deletePackagesForVersion(packageVersion) {
const packages = await listPackagesForVersion(packageVersion);
for (const packageEntry of packages) {
process.stdout.write(`Deleting stale ${packageName}/${packageVersion} package ${packageEntry.id}...\n`);
const response = await globalThis.fetch(
`${apiUrl}/projects/${encodeURIComponent(projectId)}/packages/${encodeURIComponent(packageEntry.id)}`,
{ method: "DELETE", headers: { "JOB-TOKEN": jobToken } },
);
if (!response.ok) {
throw new Error(`Package deletion failed for ${packageVersion}: HTTP ${response.status} ${await response.text()}`);
for (const reference of references) {
if (!uploadedNames.has(reference)) {
throw new Error(`${metadataFile.name} references missing release artifact ${reference}.`);
}
}
}
function readFileSyncUtf8(path) {
return readFileSync(path, "utf8");
}
async function verifyPublicLatestFile(file, packageBaseUrl) {
const publicUrl = `${packageBaseUrl}/${encodeURIComponent(safeAssetName(file.name))}?release=${encodeURIComponent(tag)}`;
const response = await globalThis.fetch(publicUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Public updater verification failed for ${file.name}: HTTP ${response.status}`);
}
const expected = await readFile(file.path);
const actual = Buffer.from(await response.arrayBuffer());
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new Error(`Public updater verification returned stale or altered ${file.name}.`);
}
process.stdout.write(`Verified public latest metadata ${file.name}.\n`);
}
async function upsertRelease(releaseUrl, description, desiredLinks) {
const headers = { "JOB-TOKEN": jobToken, "Content-Type": "application/json" };
const existingUrl = `${releaseUrl}/${encodeURIComponent(tag)}`;
const existingResponse = await globalThis.fetch(existingUrl, { headers });
if (existingResponse.status === 404) {
const createResponse = await globalThis.fetch(releaseUrl, {
method: "POST",
headers,
body: JSON.stringify({
name: `D3RO Voice ${tag}`,
tag_name: tag,
description,
assets: { links: desiredLinks },
}),
});
if (!createResponse.ok) {
throw new Error(`Release creation failed: HTTP ${createResponse.status} ${await createResponse.text()}`);
}
return;
}
if (!existingResponse.ok) {
throw new Error(`Release lookup failed: HTTP ${existingResponse.status} ${await existingResponse.text()}`);
}
const existing = await existingResponse.json();
const updateResponse = await globalThis.fetch(existingUrl, {
method: "PUT",
headers,
body: JSON.stringify({ name: `D3RO Voice ${tag}`, description }),
});
if (!updateResponse.ok) {
throw new Error(`Release update failed: HTTP ${updateResponse.status} ${await updateResponse.text()}`);
}
const existingLinks = Array.isArray(existing?.assets?.links) ? existing.assets.links : [];
for (const desired of desiredLinks) {
const match = existingLinks.find((link) => link?.name === desired.name);
const linksBase = `${existingUrl}/assets/links`;
const response = await globalThis.fetch(match ? `${linksBase}/${encodeURIComponent(match.id)}` : linksBase, {
method: match ? "PUT" : "POST",
headers,
body: JSON.stringify(desired),
});
if (!response.ok) {
throw new Error(`Release link upsert failed for ${desired.name}: HTTP ${response.status} ${await response.text()}`);
}
}
}

View file

@ -1,56 +1,40 @@
// scripts/ci/push-to-chanpaca-git.mjs
// Pushes the monorepo to the user's git.chanpaca.net remote repository
// Pushes the current checked-out commit to a pre-authorized Chanpaca remote.
// Credentials must come from Git Credential Manager or CI's GIT_ASKPASS; they
// are never embedded in a URL, command argument, or repository file.
import { spawnSync } from 'node:child_process'
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
let user = 'yunchan';
let pass = 'ONVI2v4J#y';
let repo = 'd3ro-voice';
if (existsSync('.env')) {
const envContent = readFileSync('.env', 'utf8');
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('GIT_USERNAME=')) user = trimmed.split('=')[1].trim();
if (trimmed.startsWith('GIT_PASSWORD=')) pass = trimmed.split('=')[1].trim();
if (trimmed.startsWith('GIT_REPO_NAME=')) repo = trimmed.split('=')[1].trim();
}
function requireEnvironment(name) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required`)
return value
}
const encodedPass = encodeURIComponent(pass);
const remoteUrl = `https://${user}:${encodedPass}@git.chanpaca.net/${user}/${repo}.git`;
const user = requireEnvironment('GIT_USERNAME')
const repo = requireEnvironment('GIT_REPO_NAME')
const server = (process.env.GIT_SERVER_URL?.trim() || 'https://git.chanpaca.net')
.replace(/\/$/, '')
const remoteUrl = `${server}/${encodeURIComponent(user)}/${encodeURIComponent(repo)}.git`
function run(cmd, args) {
console.log(`▶ Running: ${cmd} ${args.join(' ')}`);
const res = spawnSync(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' });
if (res.status !== 0) {
console.error(`Command failed with status ${res.status}`);
}
return res.status === 0;
function run(args) {
const result = spawnSync('git', args, {
stdio: 'inherit',
shell: false,
env: process.env,
})
if (result.status !== 0) throw new Error(`git ${args[0]} failed`)
}
console.log('--- Configuring Git Remote for git.chanpaca.net ---');
// Check if chanpaca remote exists
const remoteCheck = spawnSync('git', ['remote', 'get-url', 'chanpaca'], { encoding: 'utf8' });
const remoteCheck = spawnSync('git', ['remote', 'get-url', 'chanpaca'], {
encoding: 'utf8',
shell: false,
})
if (remoteCheck.status === 0) {
run('git', ['remote', 'set-url', 'chanpaca', remoteUrl]);
run(['remote', 'set-url', 'chanpaca', remoteUrl])
} else {
run('git', ['remote', 'add', 'chanpaca', remoteUrl]);
run(['remote', 'add', 'chanpaca', remoteUrl])
}
console.log('\n--- Staging and committing all changes ---');
run('git', ['add', '-A']);
run('git', ['commit', '-m', 'feat: add 10+ ad networks mediation, full CI/CD workflows, admin CRM, and production packaging']);
console.log('\n--- Pushing to git.chanpaca.net (main & tags) ---');
const pushMain = run('git', ['push', '-u', 'chanpaca', 'HEAD:main', '--force']);
const pushTags = run('git', ['push', 'chanpaca', '--tags']);
if (pushMain) {
console.log('\n🎉 Successfully pushed full codebase to https://git.chanpaca.net/' + user + '/' + repo);
} else {
console.error('\n❌ Push to git.chanpaca.net failed.');
process.exit(1);
}
// The caller owns staging and commit creation. Force push is intentionally not
// supported by this helper.
run(['push', '-u', 'chanpaca', 'HEAD:main'])
if (process.argv.includes('--tags')) run(['push', 'chanpaca', '--tags'])

View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_PACKAGE='com.d3ro.voice'
TEST_PACKAGE='com.d3ro.voice.test'
TEST_RUNNER='androidx.test.runner.AndroidJUnitRunner'
TEST_CLASS='com.d3ro.voice.CsprngInstrumentedTest'
APP_APK="${1:-}"
TEST_APK="${2:-}"
OUTPUT_PATH="${3:-apps/mobile-rn/.maestro-output/csprng-instrumentation.txt}"
if [[ -z "$APP_APK" || ! -f "$APP_APK" ]]; then
echo 'A debug app APK path is required.' >&2
exit 2
fi
if [[ -z "$TEST_APK" || ! -f "$TEST_APK" ]]; then
echo 'A debug AndroidTest APK path is required.' >&2
exit 2
fi
if ! command -v adb >/dev/null 2>&1; then
echo 'adb is required.' >&2
exit 2
fi
connected_devices="$(adb devices | awk '$2 == "device" { count += 1 } END { print count + 0 }')"
if [[ "$connected_devices" -ne 1 && -z "${ANDROID_SERIAL:-}" ]]; then
echo "Expected exactly one Android device, found $connected_devices. Set ANDROID_SERIAL explicitly." >&2
exit 2
fi
adb get-state | grep -Fx 'device' >/dev/null
mkdir -p "$(dirname "$OUTPUT_PATH")"
remove_if_installed() {
local package_name="$1"
if adb shell pm path "$package_name" | grep -F 'package:' >/dev/null 2>&1; then
adb uninstall "$package_name" >/dev/null
fi
}
cleanup() {
set +e
remove_if_installed "$TEST_PACKAGE"
remove_if_installed "$APP_PACKAGE"
}
trap cleanup EXIT
remove_if_installed "$TEST_PACKAGE"
remove_if_installed "$APP_PACKAGE"
adb install --no-streaming "$APP_APK"
adb install --no-streaming "$TEST_APK"
set +e
instrumentation_output="$(
adb shell am instrument -w -r \
-e class "$TEST_CLASS" \
"$TEST_PACKAGE/$TEST_RUNNER" 2>&1
)"
instrumentation_exit="$?"
set -e
instrumentation_output="${instrumentation_output//$'\r'/}"
printf '%s\n' "$instrumentation_output" | tee "$OUTPUT_PATH"
if [[ "$instrumentation_exit" -ne 0 ]]; then
echo "CSPRNG instrumentation exited with $instrumentation_exit." >&2
exit "$instrumentation_exit"
fi
if ! grep -Eq '^OK \(1 test\)$' <<<"$instrumentation_output"; then
echo 'CSPRNG instrumentation did not report exactly one passing test.' >&2
exit 1
fi
if ! grep -Eq '^INSTRUMENTATION_CODE: -1$' <<<"$instrumentation_output"; then
echo 'CSPRNG instrumentation did not report a successful runner exit.' >&2
exit 1
fi

View file

@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PACKAGE_NAME='com.d3ro.voice'
APK_PATH="${1:-}"
OUTPUT_ROOT="${2:-apps/mobile-rn/.maestro-output}"
MAESTRO_BIN="${MAESTRO_BIN:-maestro}"
if [[ -z "$APK_PATH" || ! -f "$APK_PATH" ]]; then
echo 'A bundled E2E APK path is required.' >&2
exit 2
fi
if ! command -v adb >/dev/null 2>&1; then
echo 'adb is required.' >&2
exit 2
fi
if ! command -v "$MAESTRO_BIN" >/dev/null 2>&1; then
echo 'Maestro CLI is required.' >&2
exit 2
fi
mkdir -p "$OUTPUT_ROOT"
display_override_active='false'
connected_devices="$(adb devices | awk '$2 == "device" { count += 1 } END { print count + 0 }')"
if [[ "$connected_devices" -ne 1 && -z "${ANDROID_SERIAL:-}" ]]; then
echo "Expected exactly one Android device, found $connected_devices. Set ANDROID_SERIAL explicitly." >&2
exit 2
fi
adb get-state | grep -Fx 'device' >/dev/null
collect_evidence() {
local gate_status="$1"
set +e
adb exec-out screencap -p > "$OUTPUT_ROOT/final-screen.png"
adb logcat -d > "$OUTPUT_ROOT/logcat.txt"
adb shell dumpsys package "$PACKAGE_NAME" > "$OUTPUT_ROOT/package.txt"
adb shell dumpsys activity activities > "$OUTPUT_ROOT/activities.txt"
adb shell getprop > "$OUTPUT_ROOT/device-properties.txt"
adb shell wm size > "$OUTPUT_ROOT/display-size.txt"
adb shell wm density > "$OUTPUT_ROOT/display-density.txt"
printf 'gate_status=%s\n' "$gate_status" > "$OUTPUT_ROOT/gate-status.txt"
set -e
}
on_exit() {
local exit_code="$?"
if [[ "$exit_code" -ne 0 ]]; then
collect_evidence "FAILED:$exit_code"
fi
if [[ "$display_override_active" == 'true' ]]; then
set +e
adb shell wm size reset >/dev/null
set -e
fi
exit "$exit_code"
}
trap on_exit EXIT
if adb shell pm path "$PACKAGE_NAME" | grep -F "package:" >/dev/null 2>&1; then
adb uninstall "$PACKAGE_NAME" >/dev/null
fi
if adb shell pm path "$PACKAGE_NAME" | grep -F "package:" >/dev/null 2>&1; then
echo 'Fresh-install precondition failed: package is still installed.' >&2
exit 1
fi
adb logcat -c
adb install --no-streaming "$APK_PATH"
adb shell pm path "$PACKAGE_NAME" | grep -F "package:" >/dev/null
run_flow() {
local flow_name="$1"
local flow_path="$2"
"$MAESTRO_BIN" test "$flow_path" \
--format JUNIT \
--output "apps/mobile-rn/.maestro/${flow_name}.junit.xml" \
--test-output-dir "$OUTPUT_ROOT/$flow_name"
}
run_flow 'fresh-install-auth' 'apps/mobile-rn/.maestro/fresh-install-auth.yaml'
run_flow 'fresh-install-signup' 'apps/mobile-rn/.maestro/fresh-install-signup.yaml'
run_flow 'invite-deep-link' 'apps/mobile-rn/.maestro/invite-deep-link.yaml'
adb shell wm size 840x1490 >/dev/null
display_override_active='true'
run_flow 'small-screen-signup' 'apps/mobile-rn/.maestro/fresh-install-signup.yaml'
adb shell wm size reset >/dev/null
display_override_active='false'
if [[ -n "${MOBILE_E2E_EMAIL:-}" && -n "${MOBILE_E2E_PASSWORD:-}" ]]; then
"$MAESTRO_BIN" test 'apps/mobile-rn/.maestro/authenticated-parity.yaml' \
-e MOBILE_E2E_EMAIL="$MOBILE_E2E_EMAIL" \
-e MOBILE_E2E_PASSWORD="$MOBILE_E2E_PASSWORD" \
--format JUNIT \
--output 'apps/mobile-rn/.maestro/authenticated-parity.junit.xml' \
--test-output-dir "$OUTPUT_ROOT/authenticated-parity"
printf 'RUN:authenticated-parity\n' > "$OUTPUT_ROOT/external-auth-status.txt"
authenticated_status='PASS'
elif [[ -n "${MOBILE_E2E_EMAIL:-}" || -n "${MOBILE_E2E_PASSWORD:-}" ]]; then
echo 'MOBILE_E2E_EMAIL and MOBILE_E2E_PASSWORD must be configured together.' >&2
exit 2
else
printf 'NOT_RUN:missing_MOBILE_E2E_EMAIL_and_MOBILE_E2E_PASSWORD\n' > "$OUTPUT_ROOT/external-auth-status.txt"
authenticated_status='NOT_RUN_MISSING_CREDENTIALS'
fi
printf '%s\n' \
'{' \
" \"authenticatedParity\": \"$authenticated_status\"," \
' "canonicalHttpsAppLink": "NOT_RUN_RELEASE_SIGNED_ARTIFACT_REQUIRED",' \
' "googleOAuthConsentCallback": "NOT_RUN_PROVIDER_ACCOUNT_REQUIRED",' \
' "googlePlayPurchaseAndRestore": "NOT_RUN_PLAY_DISTRIBUTION_REQUIRED",' \
' "adMobSsvCreditSettlement": "NOT_RUN_PRODUCTION_SSV_REQUIRED",' \
' "externalShareTargetReceipt": "NOT_RUN_TARGET_APP_REQUIRED"' \
'}' > "$OUTPUT_ROOT/external-gates.json"
collect_evidence 'COLLECTED'
fatal_pattern='AndroidRuntime: Process: com\.d3ro\.voice|ActivityManager: ANR in com\.d3ro\.voice|>>> com\.d3ro\.voice <<<|Unable to load script|Could not connect to development server|ReactNativeJS:.*(TypeError|ReferenceError|Invariant Violation|Unhandled promise rejection)'
fatal_count="$(grep -Ec "$fatal_pattern" "$OUTPUT_ROOT/logcat.txt" || true)"
printf 'fatal_or_metro_error_matches=%s\n' "$fatal_count" > "$OUTPUT_ROOT/fatal-scan.txt"
if [[ "$fatal_count" -ne 0 ]]; then
echo 'Fatal Android, React Native, or Metro dependency error found during clean-room E2E.' >&2
exit 1
fi
printf 'PASS\n' > "$OUTPUT_ROOT/gate-status.txt"
trap - EXIT

View file

@ -0,0 +1,147 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LocalApk,
[Parameter(Mandatory = $true)]
[string]$FinalApk,
[string]$AndroidSerial = 'emulator-5554'
)
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$maestro = Join-Path $env:USERPROFILE '.maestro\bin\maestro.bat'
$localApkPath = (Resolve-Path $LocalApk).Path
$finalApkPath = (Resolve-Path $FinalApk).Path
$canonicalBuildOutput = Join-Path $root 'apps\mobile-rn\android\app\build\outputs\apk\e2e\app-e2e.apk'
$externalProviderApk = Join-Path $root 'apps\mobile-rn\android\app\build\outputs\apk\androidTest\debug\app-debug-androidTest.apk'
if (-not (Test-Path -LiteralPath $externalProviderApk -PathType Leaf)) {
throw 'The external provider test APK is unavailable.'
}
function Invoke-Adb {
param([Parameter(Position = 0, ValueFromRemainingArguments = $true)][string[]]$Arguments)
& adb -s $AndroidSerial @Arguments
if ($LASTEXITCODE -ne 0) { throw "adb failed: $($Arguments -join ' ')" }
}
function Get-LocalSupabaseConfiguration {
$lines = & supabase status --workdir (Join-Path $root 'server') -o env 2>$null
$values = @{}
foreach ($line in $lines) {
if ($line -match '^([A-Z_]+)="(.*)"$') { $values[$matches[1]] = $matches[2] }
}
if (
$values.API_URL -ne 'http://127.0.0.1:55321' -or
$values.SECRET_KEY -notmatch '^sb_secret_[A-Za-z0-9_-]{20,}$'
) {
throw 'The disposable local Supabase boundary is unavailable.'
}
return $values
}
$config = Get-LocalSupabaseConfiguration
$baseUrl = [string]$config.API_URL
$serviceSecret = [string]$config.SECRET_KEY
$headers = @{
apikey = $serviceSecret
Authorization = "Bearer $serviceSecret"
'Content-Type' = 'application/json'
}
$nonce = [guid]::NewGuid().ToString('N')
$testDomain = @('example', 'invalid') -join '.'
$email = 'incoming-share-{0}@{1}' -f $nonce, $testDomain
$credentialPrefix = 'D3roShare'
$password = $credentialPrefix + $nonce + 'A7'
$deviceFileName = '지연된 공유 음성.wav'
$userId = $null
$primaryError = $null
try {
Write-Output 'stage=fixture-create'
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/auth/v1/admin/users" -Headers $headers -Body (@{
email = $email
password = $password
email_confirm = $true
user_metadata = @{ name = 'Incoming Share E2E'; locale = 'en' }
} | ConvertTo-Json -Depth 4)
$userId = [string]$created.id
if ($userId -notmatch '^[0-9a-f-]{36}$') { throw 'Fixture user ID is invalid.' }
$profile = @()
for ($attempt = 1; $attempt -le 30; $attempt += 1) {
$profile = @(Invoke-RestMethod -Method Get -Uri "$baseUrl/rest/v1/profiles?id=eq.$userId&select=id" -Headers $headers)
if ($profile.Count -eq 1) { break }
Start-Sleep -Milliseconds 100
}
if ($profile.Count -ne 1) { throw 'Profile trigger did not finish.' }
$patchHeaders = $headers.Clone()
$patchHeaders.Prefer = 'return=minimal'
Invoke-RestMethod -Method Patch -Uri "$baseUrl/rest/v1/profiles?id=eq.$userId" -Headers $patchHeaders -Body (@{
tier = 'pro'; locale = 'en'
} | ConvertTo-Json) | Out-Null
Invoke-RestMethod -Method Patch -Uri "$baseUrl/rest/v1/subscriptions?user_id=eq.$userId" -Headers $patchHeaders -Body (@{
tier = 'pro'; status = 'active'; provider = 'admin'; payment_provider = 'none'; overage_credits = 0
} | ConvertTo-Json) | Out-Null
$settingsHeaders = $headers.Clone()
$settingsHeaders.Prefer = 'resolution=merge-duplicates,return=minimal'
Invoke-RestMethod -Method Post -Uri "$baseUrl/rest/v1/user_settings" -Headers $settingsHeaders -Body (@{
user_id = $userId
onboarding_version = 1
locale = 'en'
} | ConvertTo-Json) | Out-Null
Write-Output 'stage=install-local-e2e'
& adb -s $AndroidSerial uninstall com.d3ro.voice.test 2>$null | Out-Null
& adb -s $AndroidSerial uninstall com.d3ro.voice 2>$null | Out-Null
Invoke-Adb -Arguments @('install', '--no-streaming', $localApkPath) | Out-Null
Invoke-Adb -Arguments @('install', '-t', '--no-streaming', $externalProviderApk) | Out-Null
$env:MAESTRO_ACTOR_EMAIL = $email
$env:MAESTRO_ACTOR_PASSWORD = $password
$env:MAESTRO_SHARED_FILE_NAME = $deviceFileName
Write-Output 'stage=login'
& $maestro test (Join-Path $root 'apps\mobile-rn\.maestro\incoming-share-local-login.yaml') `
--format JUNIT `
--output (Join-Path $root 'apps\mobile-rn\.maestro\incoming-share-local-login.junit.xml') `
--test-output-dir (Join-Path $root 'apps\mobile-rn\.maestro-output\incoming-share-local\login')
if ($LASTEXITCODE -ne 0) { throw 'Incoming share login flow failed.' }
Write-Output 'stage=external-provider-share'
$uri = 'content://com.d3ro.voice.incoming-media-test/slow-audio'
$intent = Invoke-Adb -Arguments @(
'shell', 'am', 'start', '-W', '-a', 'android.intent.action.SEND',
'-t', 'audio/wav', '--eu', 'android.intent.extra.STREAM', $uri,
'--grant-read-uri-permission', '-n', 'com.d3ro.voice/.MainActivity'
)
if (($intent -join "`n") -notmatch 'Status: ok') {
throw 'ACTION_SEND did not resolve to MainActivity.'
}
Write-Output 'stage=queue-ui-check'
& $maestro test (Join-Path $root 'apps\mobile-rn\.maestro\incoming-share-local-check.yaml') `
--format JUNIT `
--output (Join-Path $root 'apps\mobile-rn\.maestro\incoming-share-local-check.junit.xml') `
--test-output-dir (Join-Path $root 'apps\mobile-rn\.maestro-output\incoming-share-local\check')
if ($LASTEXITCODE -ne 0) { throw 'Incoming share queue flow failed.' }
Write-Output 'incoming_share_external_e2e=PASS'
} catch {
$primaryError = $_
Write-Output "incoming_share_external_e2e=FAIL:$($_.Exception.Message)"
} finally {
Remove-Item Env:MAESTRO_ACTOR_EMAIL, Env:MAESTRO_ACTOR_PASSWORD, Env:MAESTRO_SHARED_FILE_NAME -ErrorAction SilentlyContinue
if ($null -ne $userId) {
Invoke-RestMethod -Method Delete -Uri "$baseUrl/auth/v1/admin/users/$userId" -Headers $headers | Out-Null
}
Copy-Item -LiteralPath $finalApkPath -Destination $canonicalBuildOutput -Force
& adb -s $AndroidSerial uninstall com.d3ro.voice.test 2>$null | Out-Null
& adb -s $AndroidSerial uninstall com.d3ro.voice 2>$null | Out-Null
Invoke-Adb -Arguments @('install', '--no-streaming', $finalApkPath) | Out-Null
$residual = if ($null -eq $userId) { @() } else {
@(Invoke-RestMethod -Method Get -Uri "$baseUrl/rest/v1/profiles?id=eq.$userId&select=id" -Headers $headers)
}
Write-Output "fixture_residual_profiles=$($residual.Count)"
}
if ($null -ne $primaryError) { throw $primaryError }

View file

@ -1,9 +1,13 @@
// scripts/ci/sync-and-publish-forgejo-release.mjs
// 1. Copies desktop release binaries to site/public/releases/ for direct Web downloads
// 2. Creates official Release on git.chanpaca.net (Forgejo API) with release notes
// Desktop-only legacy sync. Android production artifacts are intentionally
// excluded: they may be published only by .github/workflows/release.yml after
// signed release evidence is verified in an isolated create-only directory.
import { copyFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import path from 'node:path';
import credentialHelpers from '../lib/credentials.cjs';
const { forgejoAuthorization } = credentialHelpers;
const RELEASE_DIR = 'apps/desktop/release/1.0.0';
const SITE_PUBLIC_RELEASES = 'site/public/releases/1.0.0';
@ -40,17 +44,9 @@ for (const file of filesToSync) {
}
}
// Also sync Android APK
const APK_SRC = 'apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk';
if (existsSync(APK_SRC)) {
copyFileSync(APK_SRC, path.join(SITE_PUBLIC_RELEASES, 'd3ro-voice-v1.0.0.apk'));
copyFileSync(APK_SRC, path.join(SITE_DIST_RELEASES, 'd3ro-voice-v1.0.0.apk'));
console.log('✓ Copied d3ro-voice-v1.0.0.apk to public distribution paths');
}
console.log('\n--- 2. Publishing Official Release v1.0.0 on Forgejo git.chanpaca.net ---');
const auth = Buffer.from('yunchan:ONVI2v4J#y').toString('base64');
const authorization = forgejoAuthorization();
const releasePayload = {
tag_name: 'v1.0.0',
target_commitish: 'main',
@ -61,11 +57,9 @@ const releasePayload = {
- **10+ Global Ad Mediation Engine**: Header bidding waterfall with EthicalAds, Carbon Ads, Google Ad Manager, Playwire, AppLovin, and Unity Ads.
- **Free Tier Rewarded Token Refills**: Watch 15s sponsored video to gain +50 Cloud AI tokens.
- **100% Local Whisper Large-v3-Turbo**: Complete offline speech-to-text transcription with hardware acceleration.
- **Android & iOS Mobile Edition**: Cross-platform mobile app support for on-the-go voice assistant workflows.
### 📦 Binary Checksums (SHA-256)
- \`D3RO-Voice-Setup-1.0.0-x64.exe\`: \`b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2\` (102 MB)
- \`d3ro-voice-v1.0.0.apk\`: (Android Release APK, 49.6 MB)
- \`D3RO-Voice-Setup-1.0.0-x64.exe.blockmap\`: \`795b7230bec047284785f480026d51b9247d8618e083d88cfda786d1894ca367\`
`,
draft: false,
@ -77,7 +71,7 @@ async function publishRelease() {
const res = await fetch('https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + auth,
'Authorization': authorization,
'Content-Type': 'application/json'
},
body: JSON.stringify(releasePayload)

View file

@ -1,34 +1,274 @@
// scripts/ci/sync-version.mjs
// CI_COMMIT_TAG(v0.1.0-alpha 등)에서 버전을 추출해 apps/desktop/package.json에 기록한다.
// electron-builder가 package.json version으로 설치파일명/latest.yml을 만들기 때문에
// 태그와 산출물 버전이 어긋나지 않게 패키징 전에 실행한다.
// 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 { readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const tag = process.env.CI_COMMIT_TAG ?? process.argv[2]
if (!tag) {
console.log('[sync-version] CI_COMMIT_TAG 없음 — 버전 동기화 건너뜀')
process.exit(0)
}
const version = tag.replace(/^v/, '')
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
console.error(`[sync-version] 유효하지 않은 semver: ${version} (tag: ${tag})`)
process.exit(1)
}
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const pkgPath = join(root, 'apps', 'desktop', 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const args = process.argv.slice(2)
const write = args.includes('--write')
const check = args.includes('--check') || !write
if (pkg.version === version) {
console.log(`[sync-version] 이미 동기화됨: ${version}`)
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)
}
const prev = pkg.version
pkg.version = version
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
console.log(`[sync-version] apps/desktop version: ${prev}${version}`)
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)
}

View file

@ -3,14 +3,16 @@
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import credentialHelpers from '../lib/credentials.cjs';
const auth = Buffer.from('yunchan:ONVI2v4J#y').toString('base64');
const { forgejoAuthorization } = credentialHelpers;
const authorization = forgejoAuthorization();
const EXE_PATH = 'apps/desktop/release/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe';
async function uploadAsset() {
console.log('--- Fetching release ID for v1.0.0 ---');
const relRes = await fetch('https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases/tags/v1.0.0', {
headers: { 'Authorization': 'Basic ' + auth }
headers: { 'Authorization': authorization }
});
const relData = await relRes.json();
console.log('Release ID:', relData.id, relData.name);
@ -29,7 +31,7 @@ async function uploadAsset() {
const uploadRes = await fetch(`https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases/${relData.id}/assets?name=D3RO-Voice-Setup-1.0.0-x64.exe`, {
method: 'POST',
headers: {
'Authorization': 'Basic ' + auth,
'Authorization': authorization,
},
body: formData
});

View file

@ -0,0 +1,295 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const PACKAGE_NAME = 'com.d3ro.voice'
const REQUIRED_RELATION = 'delegate_permission/common.handle_all_urls'
const LIVE_URL = 'https://d3ro.chanpaca.net/.well-known/assetlinks.json'
const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481'
const SOURCE_PATHS = [
'site/public/.well-known/assetlinks.json',
'apps/web/public/.well-known/assetlinks.json',
'apps/api-server/wwwroot/.well-known/assetlinks.json',
]
const releaseIdentity = JSON.parse(
await readFile(resolve('release/android-release-identity.json'), 'utf8'),
)
function fail(message) {
throw new Error(`android_app_links_invalid:${message}`)
}
function normalizeFingerprint(value) {
if (typeof value !== 'string') fail('certificate_fingerprint_not_string')
const normalized = value.replace(/:/g, '').toUpperCase()
if (!/^[0-9A-F]{64}$/.test(normalized)) fail('certificate_fingerprint_format')
return normalized
}
function rejectCompromisedFingerprint(value, label) {
const normalized = normalizeFingerprint(value)
if (normalized === COMPROMISED_SIGNER_SHA256.toUpperCase()) {
fail(`${label}_compromised_certificate`)
}
return normalized
}
function parseArguments(argv) {
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index]
if (!current.startsWith('--')) fail(`unexpected_argument_${current}`)
const value = argv[index + 1]
if (!value || value.startsWith('--')) fail(`missing_value_${current.slice(2)}`)
options[current.slice(2)] = value
index += 1
}
const allowed = new Set([
'expected-play-app-signing-cert-sha256',
'forbidden-upload-cert-sha256',
'live-url',
])
for (const name of Object.keys(options)) {
if (!allowed.has(name)) fail(`unexpected_argument_${name}`)
}
return options
}
function fingerprintsFromDocument(document, label) {
if (!Array.isArray(document)) fail(`${label}_root_not_array`)
const matchingEntries = document.filter((entry) => (
entry?.target?.namespace === 'android_app'
&& entry?.target?.package_name === PACKAGE_NAME
))
if (matchingEntries.length !== 1) fail(`${label}_package_entry_count_${matchingEntries.length}`)
const entry = matchingEntries[0]
if (!Array.isArray(entry.relation) || !entry.relation.includes(REQUIRED_RELATION)) {
fail(`${label}_required_relation_missing`)
}
if (!Array.isArray(entry.target.sha256_cert_fingerprints)) {
fail(`${label}_fingerprints_missing`)
}
const fingerprints = new Set(entry.target.sha256_cert_fingerprints.map((fingerprint) => (
rejectCompromisedFingerprint(fingerprint, label)
)))
if (fingerprints.size === 0) fail(`${label}_fingerprints_empty`)
if (fingerprints.size !== entry.target.sha256_cert_fingerprints.length) {
fail(`${label}_duplicate_fingerprints`)
}
return [...fingerprints].sort()
}
function rejectUploadCertificateAssociation(fingerprints, uploadFingerprint, label) {
if (uploadFingerprint && fingerprints.includes(uploadFingerprint)) {
fail(`${label}_contains_upload_certificate`)
}
}
function requireExactPlayCertificate(fingerprints, expectedFingerprint, label) {
if (!expectedFingerprint) return
if (fingerprints.length !== 1 || fingerprints[0] !== expectedFingerprint) {
fail(`${label}_play_certificate_set_mismatch`)
}
}
function validateLiveResponseMetadata(response, requestedUrl) {
if (response.status !== 200) fail(`live_http_${response.status}`)
if (response.redirected) fail('live_redirected')
if (new URL(response.url).href !== new URL(requestedUrl).href) {
fail('live_final_url_mismatch')
}
const contentType = response.headers.get('content-type') ?? ''
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
fail('live_content_type')
}
}
function runSelfTest() {
const safeFingerprint = '4FAC6924821C50DAABED764932A53C486F8C6C5F34B9F18DB920AA4099152B54'
const safeDocument = [{
relation: [REQUIRED_RELATION],
target: {
namespace: 'android_app',
package_name: PACKAGE_NAME,
sha256_cert_fingerprints: [safeFingerprint],
},
}]
if (fingerprintsFromDocument(safeDocument, 'self_test')[0] !== safeFingerprint) {
fail('self_test_safe_fingerprint_mismatch')
}
const uploadFingerprint = '7A'.repeat(32)
rejectUploadCertificateAssociation([safeFingerprint], uploadFingerprint, 'self_test_safe')
let uploadAssociationRejected = false
try {
rejectUploadCertificateAssociation([uploadFingerprint], uploadFingerprint, 'self_test')
} catch (error) {
uploadAssociationRejected = error instanceof Error
&& error.message.includes('self_test_contains_upload_certificate')
}
if (!uploadAssociationRejected) fail('self_test_upload_certificate_association_not_rejected')
let compromisedRejected = false
try {
fingerprintsFromDocument([{
...safeDocument[0],
target: {
...safeDocument[0].target,
sha256_cert_fingerprints: [COMPROMISED_SIGNER_SHA256],
},
}], 'self_test')
} catch (error) {
compromisedRejected = error instanceof Error
&& error.message.includes('self_test_compromised_certificate')
}
if (!compromisedRejected) fail('self_test_compromised_certificate_not_rejected')
let legacyArgumentRejected = false
try {
parseArguments(['--expected-cert-sha256', safeFingerprint])
} catch (error) {
legacyArgumentRejected = error instanceof Error
&& error.message.includes('unexpected_argument_expected-cert-sha256')
}
if (!legacyArgumentRejected) fail('self_test_legacy_certificate_argument_not_rejected')
requireExactPlayCertificate([safeFingerprint], safeFingerprint, 'self_test_safe')
let extraCertificateRejected = false
try {
requireExactPlayCertificate([safeFingerprint, uploadFingerprint].sort(), safeFingerprint, 'self_test')
} catch (error) {
extraCertificateRejected = error instanceof Error
&& error.message.includes('self_test_play_certificate_set_mismatch')
}
if (!extraCertificateRejected) fail('self_test_extra_certificate_not_rejected')
validateLiveResponseMetadata({
status: 200,
redirected: false,
url: LIVE_URL,
headers: new Headers({ 'content-type': 'application/json; charset=utf-8' }),
}, LIVE_URL)
for (const [label, response] of [
['redirect', {
status: 302,
redirected: false,
url: LIVE_URL,
headers: new Headers({ 'content-type': 'application/json' }),
}],
['mime', {
status: 200,
redirected: false,
url: LIVE_URL,
headers: new Headers({ 'content-type': 'text/plain' }),
}],
['final_url', {
status: 200,
redirected: true,
url: `${LIVE_URL}?redirected=1`,
headers: new Headers({ 'content-type': 'application/json' }),
}],
]) {
let rejected = false
try {
validateLiveResponseMetadata(response, LIVE_URL)
} catch (error) {
rejected = error instanceof Error && error.message.startsWith('android_app_links_invalid:')
}
if (!rejected) fail(`self_test_${label}_metadata_not_rejected`)
}
process.stdout.write(`${JSON.stringify({
ok: true,
compromisedCertificateRejected: true,
exactPlayCertificateRequired: true,
liveHttpMetadataRequired: true,
uploadCertificateAssociationRejected: true,
})}\n`)
}
const cliArguments = process.argv.slice(2)
if (cliArguments.length === 1 && cliArguments[0] === '--self-test') {
runSelfTest()
process.exit(0)
}
const options = parseArguments(cliArguments)
if (
releaseIdentity.schemaVersion !== 1
|| releaseIdentity.packageName !== PACKAGE_NAME
|| typeof releaseIdentity.playConsoleAppId !== 'string'
|| !/^\d+$/.test(releaseIdentity.playConsoleAppId)
) {
fail('release_identity_invalid')
}
const canonicalExpectedFingerprint = rejectCompromisedFingerprint(
releaseIdentity.playAppSigningCertificateSha256,
'release_identity_play_app_signing',
)
const expectedFingerprint = options['expected-play-app-signing-cert-sha256']
? rejectCompromisedFingerprint(
options['expected-play-app-signing-cert-sha256'],
'expected_play_app_signing',
)
: canonicalExpectedFingerprint
if (expectedFingerprint !== canonicalExpectedFingerprint) {
fail('expected_play_certificate_identity_mismatch')
}
const canonicalUploadFingerprint = rejectCompromisedFingerprint(
releaseIdentity.uploadCertificateSha256,
'release_identity_upload',
)
const forbiddenUploadFingerprint = options['forbidden-upload-cert-sha256']
? rejectCompromisedFingerprint(options['forbidden-upload-cert-sha256'], 'upload')
: canonicalUploadFingerprint
if (forbiddenUploadFingerprint !== canonicalUploadFingerprint) {
fail('upload_certificate_identity_mismatch')
}
if (expectedFingerprint && expectedFingerprint === forbiddenUploadFingerprint) {
fail('play_and_upload_certificates_must_differ')
}
const sourceEvidence = []
for (const path of SOURCE_PATHS) {
const document = JSON.parse(await readFile(resolve(path), 'utf8'))
const fingerprints = fingerprintsFromDocument(document, path)
rejectUploadCertificateAssociation(fingerprints, forbiddenUploadFingerprint, path)
sourceEvidence.push({ path, fingerprints })
}
const canonicalFingerprints = sourceEvidence[0].fingerprints
for (const evidence of sourceEvidence.slice(1)) {
if (JSON.stringify(evidence.fingerprints) !== JSON.stringify(canonicalFingerprints)) {
fail(`source_fingerprint_drift_${evidence.path}`)
}
}
if (expectedFingerprint && !canonicalFingerprints.includes(expectedFingerprint)) {
fail('release_certificate_missing_from_source_assetlinks')
}
requireExactPlayCertificate(canonicalFingerprints, expectedFingerprint, 'source')
const liveUrl = options['live-url'] ?? LIVE_URL
const response = await fetch(liveUrl, {
headers: { accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(15_000),
})
validateLiveResponseMetadata(response, liveUrl)
let liveDocument
try {
liveDocument = await response.json()
} catch {
fail('live_invalid_json')
}
const liveFingerprints = fingerprintsFromDocument(liveDocument, 'live')
rejectUploadCertificateAssociation(liveFingerprints, forbiddenUploadFingerprint, 'live')
if (JSON.stringify(liveFingerprints) !== JSON.stringify(canonicalFingerprints)) {
fail('live_source_fingerprint_drift')
}
if (expectedFingerprint && !liveFingerprints.includes(expectedFingerprint)) {
fail('release_certificate_missing_from_live_assetlinks')
}
requireExactPlayCertificate(liveFingerprints, expectedFingerprint, 'live')
console.log(JSON.stringify({
packageName: PACKAGE_NAME,
relation: REQUIRED_RELATION,
liveUrl,
fingerprints: canonicalFingerprints,
playConsoleAppId: releaseIdentity.playConsoleAppId,
expectedPlayAppSigningCertificateVerified: true,
uploadCertificateExcluded: true,
sourcePaths: SOURCE_PATHS,
}, null, 2))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,408 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const ROOT = resolve(import.meta.dirname, '../..')
const BUILD_GRADLE = resolve(ROOT, 'apps/mobile-rn/android/app/build.gradle')
const ROOT_BUILD_GRADLE = resolve(ROOT, 'apps/mobile-rn/android/build.gradle')
const PRODUCT_VERSION = resolve(ROOT, 'release/product-version.json')
const CONFIG_MODULE = resolve(
ROOT,
'apps/mobile-rn/android/app/src/main/java/com/d3ro/voice/D3ROConfigModule.kt',
)
const GOOGLE_TEST_PUBLISHER_ID = 'ca-app-pub-3940256099942544'
const TEST_PUBLISHER_PREFIX = `${GOOGLE_TEST_PUBLISHER_ID}/`
const TEST_BANNER_ID = `${TEST_PUBLISHER_PREFIX}6300978111`
const TEST_REWARDED_ID = `${TEST_PUBLISHER_PREFIX}5224354917`
const VALID_VARIANTS = new Set(['debug', 'e2e', 'release'])
function fail(message) {
throw new Error(`mobile_build_config_invalid:${message}`)
}
function requireMatch(source, expression, message) {
if (!expression.test(source)) fail(message)
}
function extractBlock(source, marker, message) {
const markerIndex = source.search(marker)
if (markerIndex < 0) fail(message)
const openIndex = source.indexOf('{', markerIndex)
if (openIndex < 0) fail(message)
let depth = 0
for (let index = openIndex; index < source.length; index += 1) {
if (source[index] === '{') depth += 1
if (source[index] === '}') {
depth -= 1
if (depth === 0) return source.slice(openIndex + 1, index)
}
}
fail(`${message}_unclosed`)
}
function parseBooleanConstant(source, name) {
const match = source.match(new RegExp(
`public static final boolean ${name} = (?:Boolean\\.parseBoolean\\(\"(true|false)\"\\)|(true|false));`,
))
if (!match) fail(`missing_boolean_${name}`)
return (match[1] ?? match[2]) === 'true'
}
function parseStringConstant(source, name) {
const match = source.match(new RegExp(`public static final String ${name} = \"([^\"]*)\";`))
if (!match) fail(`missing_string_${name}`)
return match[1]
}
function parseIntegerConstant(source, name) {
const match = source.match(new RegExp(`public static final int ${name} = ([0-9]+);`))
if (!match) fail(`missing_integer_${name}`)
return Number(match[1])
}
async function readSourceContractFiles() {
const [gradle, rootGradle, configModule, productVersionSource] = await Promise.all([
readFile(BUILD_GRADLE, 'utf8'),
readFile(ROOT_BUILD_GRADLE, 'utf8'),
readFile(CONFIG_MODULE, 'utf8'),
readFile(PRODUCT_VERSION, 'utf8'),
])
return { gradle, rootGradle, configModule, productVersion: JSON.parse(productVersionSource) }
}
function verifySourceContractSources({ gradle, rootGradle, configModule, productVersion }) {
if (/gradle\.startParameter\.taskNames/.test(gradle)) {
fail('variant_gate_must_not_use_requested_task_name_heuristics')
}
if (/gradle\.taskGraph|whenReady\s*\(/.test(gradle)) {
fail('variant_gate_must_not_use_configuration_cache_unsafe_task_graph_listeners')
}
const defaultConfigBlock = extractBlock(gradle, /\bdefaultConfig\s*\{/, 'missing_default_config')
const e2eBlock = extractBlock(gradle, /\be2e\s*\{/, 'missing_e2e_build_type')
const debugBlock = extractBlock(gradle, /\bdebug\s*\{/, 'missing_debug_build_type')
const releaseBlock = extractBlock(gradle, /\brelease\s*\{/, 'missing_release_build_type')
const gateTypeBlock = extractBlock(
gradle,
/abstract class VerifyD3roMobileVariantConfiguration extends DefaultTask\s*\{/,
'missing_variant_configuration_gate_type',
)
const e2eGateBlock = extractBlock(
gradle,
/tasks\.register\(\s*"verifyE2eBuildConfiguration"/,
'missing_e2e_configuration_gate',
)
const releaseGateBlock = extractBlock(
gradle,
/tasks\.register\(\s*"verifyReleaseBuildConfiguration"/,
'missing_release_configuration_gate',
)
const lifecycleWiringBlock = extractBlock(
gradle,
/tasks\.configureEach\s*\{/,
'missing_variant_lifecycle_gate_wiring',
)
requireMatch(
defaultConfigBlock,
/buildConfigField\s+"boolean",\s*"E2E_TEST_BUILD",\s*"false"/,
'default_e2e_flag_must_be_false',
)
requireMatch(e2eBlock, /debuggable\s+false/, 'e2e_must_not_be_debuggable')
requireMatch(
e2eBlock,
/matchingFallbacks\s*=\s*\["release"\]/,
'e2e_native_dependencies_must_match_non_debug_abi',
)
requireMatch(
e2eBlock,
/buildConfigField\s+"boolean",\s*"E2E_TEST_BUILD",\s*"true"/,
'e2e_flag_must_be_true',
)
requireMatch(e2eBlock, /abiFilters\.add\("arm64-v8a"\)/, 'e2e_arm64_abi_missing')
requireMatch(e2eBlock, /abiFilters\.add\("x86_64"\)/, 'e2e_x86_64_abi_missing')
if (/"E2E_TEST_BUILD",\s*"true"/.test(debugBlock)) fail('debug_e2e_flag_must_not_be_true')
if (/"E2E_TEST_BUILD",\s*"true"/.test(releaseBlock)) fail('release_e2e_flag_must_not_be_true')
const trueAssignments = [...gradle.matchAll(/"E2E_TEST_BUILD",\s*"true"/g)]
if (trueAssignments.length !== 1) fail('e2e_true_assignment_must_be_unique')
requireMatch(
gradle,
/debuggableVariants\s*=\s*\["debug"\]/,
'only_debug_may_skip_embedded_bundle',
)
requireMatch(
gradle,
/configuredVersionName\s*=\s*secureSetting\("D3RO_VERSION_NAME"\)/,
'version_name_must_come_from_secure_setting',
)
requireMatch(
gradle,
/configuredVersionCode\s*=\s*secureSetting\("D3RO_VERSION_CODE"\)/,
'version_code_must_come_from_secure_setting',
)
if (typeof productVersion?.version !== 'string') fail('product_version_name_invalid')
if (!Number.isSafeInteger(productVersion?.androidVersionCode) || productVersion.androidVersionCode <= 0) {
fail('product_version_code_invalid')
}
const fallbackVersionName = gradle.match(
/resolvedVersionName\s*=\s*versionSettingsValid\s*\?\s*configuredVersionName\s*:\s*"([^"]+)"/,
)?.[1]
if (fallbackVersionName !== productVersion.version) {
fail('validated_version_name_must_feed_product_version')
}
const fallbackVersionCode = Number(gradle.match(
/resolvedVersionCode\s*=\s*versionSettingsValid\s*\?\s*configuredVersionCodeValue\.toInteger\(\)\s*:\s*(\d+)/,
)?.[1])
if (fallbackVersionCode !== productVersion.androidVersionCode) {
fail('validated_version_code_must_feed_product_version')
}
requireMatch(
gateTypeBlock,
/@org\.gradle\.api\.tasks\.TaskAction\s+void\s+verifyConfiguration\(\)/,
'variant_gate_must_be_an_execution_time_task',
)
for (const [pattern, message] of [
[
/private static final String GOOGLE_TEST_ADMOB_PUBLISHER_ID\s*=\s*"ca-app-pub-3940256099942544"/,
'release_gate_google_test_publisher_constant_required',
],
[/D3RO_VERSION_NAME is required for release and e2e builds/, 'variant_gate_version_name_required'],
[/D3RO_VERSION_NAME must be a strict semantic version/, 'variant_gate_semver_required'],
[/D3RO_VERSION_CODE must be a positive decimal integer/, 'variant_gate_version_code_required'],
[/D3RO_VERSION_CODE exceeds the Android maximum/, 'variant_gate_version_code_max_required'],
[/google-services\.json for com\.d3ro\.voice/, 'release_gate_firebase_required'],
[/missingReleaseSettings\.get\(\)/, 'release_gate_missing_settings_required'],
[/D3RO_RELEASE_STORE_FILE must point to an existing keystore/, 'release_gate_keystore_required'],
[/D3RO_ADMOB_APP_ID has an invalid format/, 'release_gate_admob_app_id_required'],
[
/adMobAppId\.startsWith\(\s*GOOGLE_TEST_ADMOB_PUBLISHER_ID\s*\+\s*"~"\s*\)/,
'release_gate_admob_app_test_publisher_rejection_required',
],
[/D3RO_ADMOB_BANNER_UNIT_ID:\s*productionBannerUnitId\.get\(\)/, 'release_gate_banner_id_required'],
[/D3RO_ADMOB_REWARDED_UNIT_ID:\s*productionRewardedUnitId\.get\(\)/, 'release_gate_rewarded_id_required'],
[
/value\.startsWith\(\s*GOOGLE_TEST_ADMOB_PUBLISHER_ID\s*\+\s*"\/"\s*\)/,
'release_gate_admob_unit_test_publisher_rejection_required',
],
]) {
requireMatch(gateTypeBlock, pattern, message)
}
requireMatch(e2eGateBlock, /variantName\.set\("e2e"\)/, 'e2e_gate_variant_identity_missing')
requireMatch(
e2eGateBlock,
/configureVersionGate\(delegate\)/,
'e2e_gate_must_receive_version_contract',
)
requireMatch(
releaseGateBlock,
/variantName\.set\("release"\)/,
'release_gate_variant_identity_missing',
)
requireMatch(
releaseGateBlock,
/configureVersionGate\(delegate\)/,
'release_gate_must_receive_version_contract',
)
for (const [pattern, message] of [
[/missingReleaseSettings\.set\(missingReleaseSettingNames\)/, 'release_gate_missing_settings_input_missing'],
[/firebaseConfigPath\.set\(firebaseConfigFile\.absolutePath\)/, 'release_gate_firebase_input_missing'],
[/releaseStoreFilePath\.set\(releaseStoreAbsolutePath\)/, 'release_gate_keystore_input_missing'],
[/productionAdMobAppId\.set\(releaseAdMobAppIdValue\)/, 'release_gate_admob_app_input_missing'],
[/productionBannerUnitId\.set\(releaseBannerUnitIdValue\)/, 'release_gate_banner_input_missing'],
[/productionRewardedUnitId\.set\(releaseRewardedUnitIdValue\)/, 'release_gate_rewarded_input_missing'],
]) {
requireMatch(releaseGateBlock, pattern, message)
}
requireMatch(
lifecycleWiringBlock,
/candidate\.name\s*==\s*"preE2eBuild"[\s\S]*candidate\.dependsOn\(verifyE2eBuildConfiguration\)/,
'e2e_gate_must_be_wired_to_pre_e2e_build',
)
requireMatch(
lifecycleWiringBlock,
/candidate\.name\s*==\s*"preReleaseBuild"[\s\S]*candidate\.dependsOn\(verifyReleaseBuildConfiguration\)/,
'release_gate_must_be_wired_to_pre_release_build',
)
requireMatch(
configModule,
/"debug"\s+to\s+\(BuildConfig\.DEBUG\s*\|\|\s*BuildConfig\.E2E_TEST_BUILD\)/,
'native_runtime_must_recognize_non_debuggable_e2e',
)
requireMatch(
rootGradle,
/e2eRequested\s*=\s*requestedBuildTasks\.any\s*\{\s*it\.contains\("e2e"\)\s*\}/,
'root_gradle_must_detect_e2e_tasks',
)
requireMatch(
rootGradle,
/isUniversalNativeBuild\s*=\s*e2eRequested\s*\|\|/,
'root_gradle_e2e_must_be_universal',
)
requireMatch(
rootGradle,
/requestedArchitectures\.toSet\(\)\s*!=\s*\["arm64-v8a",\s*"x86_64"\]\.toSet\(\)/,
'explicit_universal_architectures_must_be_exact',
)
}
async function verifySourceContract() {
verifySourceContractSources(await readSourceContractFiles())
}
function expectSourceContractFailure(sources, mutate, expectedMessage) {
let failedAsExpected = false
try {
verifySourceContractSources({ ...sources, gradle: mutate(sources.gradle) })
} catch (error) {
if (!(error instanceof Error) || !error.message.includes(expectedMessage)) throw error
failedAsExpected = true
}
if (!failedAsExpected) fail(`self_test_missed_${expectedMessage}`)
}
function verifyVariant(variant, buildConfig) {
const debug = parseBooleanConstant(buildConfig, 'DEBUG')
const e2eTestBuild = parseBooleanConstant(buildConfig, 'E2E_TEST_BUILD')
const buildType = parseStringConstant(buildConfig, 'BUILD_TYPE')
const versionName = parseStringConstant(buildConfig, 'VERSION_NAME')
const versionCode = parseIntegerConstant(buildConfig, 'VERSION_CODE')
const bannerUnitId = parseStringConstant(buildConfig, 'ADMOB_BANNER_UNIT_ID')
const rewardedUnitId = parseStringConstant(buildConfig, 'ADMOB_REWARDED_UNIT_ID')
const runtimeDebug = debug || e2eTestBuild
if (buildType !== variant) fail(`build_type_${buildType}_expected_${variant}`)
if (variant === 'debug') {
if (!debug || e2eTestBuild || !runtimeDebug) fail('debug_identity_mismatch')
if (bannerUnitId !== TEST_BANNER_ID || rewardedUnitId !== TEST_REWARDED_ID) {
fail('debug_must_use_google_test_units')
}
}
if (variant === 'e2e') {
if (debug || !e2eTestBuild || !runtimeDebug) fail('e2e_identity_mismatch')
if (bannerUnitId !== TEST_BANNER_ID || rewardedUnitId !== TEST_REWARDED_ID) {
fail('e2e_must_use_google_test_units')
}
}
if (variant === 'release') {
if (debug || e2eTestBuild || runtimeDebug) fail('release_identity_mismatch')
for (const [name, value] of Object.entries({ bannerUnitId, rewardedUnitId })) {
if (!/^ca-app-pub-\d+\/\d+$/.test(value)) fail(`release_${name}_format`)
if (value.startsWith(TEST_PUBLISHER_PREFIX)) fail(`release_${name}_uses_test_publisher`)
}
const expectedBanner = process.env.D3RO_ADMOB_BANNER_UNIT_ID
const expectedRewarded = process.env.D3RO_ADMOB_REWARDED_UNIT_ID
if (expectedBanner && bannerUnitId !== expectedBanner) fail('release_banner_env_mismatch')
if (expectedRewarded && rewardedUnitId !== expectedRewarded) fail('release_rewarded_env_mismatch')
}
if (variant === 'e2e' || variant === 'release') {
const expectedVersionName = process.env.D3RO_VERSION_NAME
const expectedVersionCode = Number(process.env.D3RO_VERSION_CODE)
if (!expectedVersionName) fail(`${variant}_expected_version_name_missing`)
if (!Number.isSafeInteger(expectedVersionCode) || expectedVersionCode <= 0) {
fail(`${variant}_expected_version_code_invalid`)
}
if (versionName !== expectedVersionName) fail(`${variant}_version_name_mismatch`)
if (versionCode !== expectedVersionCode) fail(`${variant}_version_code_mismatch`)
}
return {
variant,
buildType,
debuggableRuntime: debug,
e2eTestBuild,
runtimeDebug,
versionName,
versionCode,
adPublisher: bannerUnitId.split('/')[0],
}
}
if (process.argv.includes('--self-test')) {
const sources = await readSourceContractFiles()
verifySourceContractSources(sources)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace('candidate.name == "preE2eBuild"', 'candidate.name == "preE2eBuildDisabled"'),
'e2e_gate_must_be_wired_to_pre_e2e_build',
)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace('candidate.name == "preReleaseBuild"', 'candidate.name == "preReleaseBuildDisabled"'),
'release_gate_must_be_wired_to_pre_release_build',
)
expectSourceContractFailure(
sources,
(gradle) => `def requested = gradle.startParameter.taskNames\n${gradle}`,
'variant_gate_must_not_use_requested_task_name_heuristics',
)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace(
'firebaseConfigPath.set(firebaseConfigFile.absolutePath)',
'firebaseConfigPath.set("")',
),
'release_gate_firebase_input_missing',
)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace(
'GOOGLE_TEST_ADMOB_PUBLISHER_ID + "~"',
'"disabled-google-test-publisher~"',
),
'release_gate_admob_app_test_publisher_rejection_required',
)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace(
'GOOGLE_TEST_ADMOB_PUBLISHER_ID + "/"',
'"disabled-google-test-publisher/"',
),
'release_gate_admob_unit_test_publisher_rejection_required',
)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace(
'configureVersionGate(delegate)',
'configureVersionGateDisabled(delegate)',
),
'e2e_gate_must_receive_version_contract',
)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace(
`: "${sources.productVersion.version}"`,
': "9.9.9"',
),
'validated_version_name_must_feed_product_version',
)
expectSourceContractFailure(
sources,
(gradle) => gradle.replace(
`: ${sources.productVersion.androidVersionCode}`,
': 1',
),
'validated_version_code_must_feed_product_version',
)
console.log('Mobile build configuration gate self-test passed.')
process.exit(0)
}
await verifySourceContract()
const variant = process.argv[2]
if (variant === undefined) {
console.log(JSON.stringify({ sourceContract: 'verified' }))
process.exit(0)
}
if (!VALID_VARIANTS.has(variant)) fail(`unknown_variant_${variant}`)
const generatedPath = resolve(
process.argv[3]
?? `apps/mobile-rn/android/app/build/generated/source/buildConfig/${variant}/com/d3ro/voice/BuildConfig.java`,
)
const buildConfig = await readFile(generatedPath, 'utf8')
console.log(JSON.stringify({
sourceContract: 'verified',
generatedPath,
...verifyVariant(variant, buildConfig),
}, null, 2))

View file

@ -0,0 +1,818 @@
import { generateKeyPairSync } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import {
existsSync,
linkSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
buildReleasePayload,
canonicalJson,
COMPROMISED_SIGNER_SHA256,
createImmutableVerificationSnapshot,
hashRegularFileStable,
prepareVerifiedReleasePublication,
RELEASE_AAB_NAME,
RELEASE_APK_NAME,
RELEASE_PACKAGE_NAME,
signReleaseEvidence,
SIGNED_EVIDENCE_NAME,
writeJsonCreateOnly,
} from './mobile-release-evidence-lib.mjs'
const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
const require = createRequire(import.meta.url)
const { assertNoMobileArtifacts } = require('../deploy-site-to-nas.js')
const productionAdMobId = 'ca-app-pub-1234567890123456~1234567890'
const productionSigner = '4fac6924821c50daabed764932a53c486f8c6c5f34b9f18db920aa4099152b54'
const versionName = '9.8.7'
const versionCode = 9_008_007
const releaseProvenance = {
repository: 'chanpaca/D3ROVoice',
commitSha: 'a'.repeat(40),
treeSha: 'b'.repeat(40),
gitRef: 'refs/tags/v9.8.7',
workflowIdentity: 'github:.github/workflows/release.yml:package-android',
runId: '123456789',
runAttempt: 1,
runnerIdentity: 'github-hosted:ubuntu-latest',
verifierSha256: 'c'.repeat(64),
bundletoolSha256: 'd'.repeat(64),
}
function fail(code) {
throw new Error(`mobile_release_boundary_self_test:${code}`)
}
function assert(condition, code) {
if (!condition) fail(code)
}
function readWorkspaceFile(relativePath) {
return readFileSync(resolve(workspaceRoot, relativePath), 'utf8')
}
function runVerifierSelfTest(relativePath) {
const result = spawnSync(process.execPath, [resolve(workspaceRoot, relativePath), '--self-test'], {
cwd: workspaceRoot,
encoding: 'utf8',
windowsHide: true,
})
assert(result.status === 0, `verifier_self_test_failed_${relativePath}_${result.stderr || result.stdout}`)
}
function runCliExpectedFailure(relativePath, options, omitted, expectedCode) {
const argumentsList = Object.entries(options)
.filter(([name]) => name !== omitted)
.flatMap(([name, value]) => [`--${name}`, String(value)])
const result = spawnSync(process.execPath, [resolve(workspaceRoot, relativePath), ...argumentsList], {
cwd: workspaceRoot,
encoding: 'utf8',
windowsHide: true,
})
const output = `${result.stderr ?? ''}${result.stdout ?? ''}`
assert(result.status !== 0, `cli_missing_argument_accepted_${relativePath}_${omitted}`)
assert(
output.includes(expectedCode),
`cli_missing_argument_wrong_failure_${relativePath}_${omitted}_${output}`,
)
}
function listScripts(directory) {
const result = []
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.name === 'node_modules') continue
const target = join(directory, entry.name)
if (entry.isDirectory()) result.push(...listScripts(target))
else if (/\.(?:cjs|js|mjs)$/.test(entry.name)) result.push(target)
}
return result
}
function verifySourceContracts() {
const legacySync = readWorkspaceFile('scripts/ci/sync-and-publish-forgejo-release.mjs')
for (const forbidden of [
'app-debug.apk',
'd3ro-voice-v1.0.0.apk',
'Android Release APK',
]) {
assert(!legacySync.includes(forbidden), `legacy_sync_contains_${forbidden.replace(/[^a-z0-9]+/gi, '_')}`)
}
const retiredCapture = readWorkspaceFile('scripts/capture-pricing.js')
for (const forbidden of [
'assembleDebug',
'app-debug.apk',
'apksigner',
'copyFileSync',
'execSync',
'fetch(',
'forgejoAuthorization',
'writeFileSync',
]) {
assert(!retiredCapture.includes(forbidden), `retired_capture_contains_${forbidden.replace(/[^a-z0-9]+/gi, '_')}`)
}
assert(retiredCapture.includes('LEGACY_MOBILE_RELEASE_PIPELINE_DISABLED'), 'retired_capture_marker_missing')
const releaseWorkflow = readWorkspaceFile('.github/workflows/release.yml')
for (const required of [
'create-mobile-release-evidence.mjs',
'prepare-mobile-release-publication.mjs',
'ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64',
'ANDROID_UPLOAD_CERT_SHA256',
'release/android-release-identity.json',
'release/mobile-release-evidence-public.pem',
'--expected-admob-app-id',
'--expected-upload-cert-sha256',
'--repository "$GITHUB_REPOSITORY"',
'--commit-sha "$GITHUB_SHA"',
'--tree-sha "$(git rev-parse',
'--git-ref "$GITHUB_REF"',
'--workflow-identity "$GITHUB_WORKFLOW_REF"',
'--run-id "$GITHUB_RUN_ID"',
'--run-attempt "$GITHUB_RUN_ATTEMPT"',
'--runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH"',
'test "$GITHUB_SHA" = "$(git rev-parse HEAD)"',
'git status --porcelain --untracked-files=all',
'--expected-play-app-signing-cert-sha256',
'--forbidden-upload-cert-sha256',
'--expected-version-name',
'--expected-version-code',
'--bundletool',
'--snapshot-dir',
'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29',
'environment: mobile-production-release',
'fetch-depth: 0',
'git merge-base --is-ancestor',
'tag_name: ${{ steps.release-identity.outputs.tag }}',
'overwrite_files: false',
'name: android-play-console-handoff',
'REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}',
'test "$REPOSITORY_VISIBILITY" = "private"',
'release-publication/app-release.aab',
'release-publication/android-release-evidence.json',
'release-publication/android-publication-manifest.json',
'release-snapshot/release-artifact-verification.json',
'retention-days: 7',
]) {
assert(releaseWorkflow.includes(required), `release_workflow_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
}
for (const forbidden of [
'ANDROID_RELEASE_CERT_SHA256',
'--expected-cert-sha256',
'ANDROID_RELEASE_EVIDENCE_PUBLIC_KEY_B64',
'name: android-release-assets',
'path: android-source/',
'release-dist/android',
]) {
assert(!releaseWorkflow.includes(forbidden), `release_workflow_forbidden_${forbidden.replace(/[^a-z0-9]+/gi, '_')}`)
}
const handoffStep = releaseWorkflow.match(
/- name: Upload Restricted Play Console AAB Handoff[\s\S]*?(?=\n\s{6}- name:|\n\s{2}#)/,
)?.[0] ?? ''
assert(handoffStep.includes('app-release.aab'), 'restricted_handoff_aab_missing')
assert(!handoffStep.includes('app-release.apk'), 'restricted_handoff_contains_upload_key_apk')
const publicReleaseStep = releaseWorkflow.slice(releaseWorkflow.indexOf('- name: Create GitHub Release'))
assert(!publicReleaseStep.includes('.apk'), 'public_release_contains_android_apk')
assert(!publicReleaseStep.includes('.aab'), 'public_release_contains_android_aab')
assert(!publicReleaseStep.includes('android-play-console-handoff'), 'public_release_contains_android_handoff')
assert(releaseWorkflow.includes('test "$GITHUB_SHA" = "$(git rev-parse origin/main)"'), 'dispatch_main_sha_guard_missing')
assert(releaseWorkflow.includes('test "$RELEASE_TAG" = "v$VERSION_NAME"'), 'release_tag_identity_guard_missing')
const packageJson = JSON.parse(readWorkspaceFile('package.json'))
assert(
packageJson.scripts?.['release:mobile:boundary'] === 'node scripts/ci/verify-mobile-release-boundary.mjs',
'package_boundary_script_missing',
)
assert(
packageJson.scripts?.['release:mobile:boundary:test'] === 'node scripts/ci/verify-mobile-release-boundary.mjs --self-test',
'package_boundary_self_test_script_missing',
)
const evidenceCreator = readWorkspaceFile('scripts/ci/create-mobile-release-evidence.mjs')
for (const required of [
'verify-android-artifact.mjs',
"'--mode', 'release'",
"'--expected-admob-app-id'",
"'--expected-upload-cert-sha256'",
"'--expected-version-name'",
"'--expected-version-code'",
"'--bundletool'",
'createImmutableVerificationSnapshot',
'signReleaseEvidence',
"'repository'",
"'commit-sha'",
"'tree-sha'",
"'git-ref'",
"'workflow-identity'",
"'run-id'",
"'run-attempt'",
"'runner-identity'",
'hashRegularFileStable(snapshot.verifierPath)',
'hashRegularFileStable(snapshot.bundletoolPath)',
'verifier_changed_during_verification',
'bundletool_changed_during_verification',
]) {
assert(evidenceCreator.includes(required), `evidence_creator_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
}
assert(!evidenceCreator.includes("'expected-cert-sha256'"), 'evidence_creator_legacy_certificate_argument')
const publicationPreparer = readWorkspaceFile('scripts/ci/prepare-mobile-release-publication.mjs')
for (const required of [
"'expected-upload-cert-sha256'",
"'expected-repository'",
"'expected-commit-sha'",
"'expected-tree-sha'",
"'expected-git-ref'",
"'expected-workflow-identity'",
"'expected-run-id'",
"'expected-run-attempt'",
"'expected-runner-identity'",
"'expected-verifier-sha256'",
"'expected-bundletool-sha256'",
]) {
assert(
publicationPreparer.includes(required),
`publication_preparer_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`,
)
}
assert(
!publicationPreparer.includes("'expected-cert-sha256'"),
'publication_preparer_legacy_certificate_argument',
)
const excluded = new Set([
resolve(workspaceRoot, 'scripts/ci/verify-mobile-release-boundary.mjs'),
])
for (const scriptPath of listScripts(resolve(workspaceRoot, 'scripts'))) {
if (excluded.has(scriptPath)) continue
const source = readFileSync(scriptPath, 'utf8')
const consumesDebugApk = /outputs[\\/]apk[\\/]debug[\\/]app-debug\.apk/i.test(source)
const publishesArtifact = /(copyFile|upload|forgejo|release asset|fetch\s*\()/i.test(source)
assert(!(consumesDebugApk && publishesArtifact), `debug_apk_publish_script_${relative(workspaceRoot, scriptPath)}`)
}
const evidenceLibrary = readWorkspaceFile('scripts/ci/mobile-release-evidence-lib.mjs')
for (const required of [
'O_NOFOLLOW',
'fstatSync',
'COPYFILE_EXCL',
'source_changed_during_snapshot',
'hardlink_rejected',
'reparse_rejected',
'destination_directory_replaced',
COMPROMISED_SIGNER_SHA256,
'compromised_signer_rejected',
'RELEASE_EVIDENCE_SCHEMA_VERSION = 2',
'validateReleaseProvenance',
'publication_provenance_',
]) {
assert(evidenceLibrary.includes(required), `toctou_contract_missing_${required}`)
}
const artifactVerifier = readWorkspaceFile('scripts/ci/verify-android-artifact.mjs')
for (const required of [
'expected-upload-cert-sha256',
"'dump', 'manifest'",
"'jarsigner'",
"'keytool'",
'aab_package_name_mismatch',
'aab_version_name_mismatch',
'aab_admob_application_id_mismatch',
'aab_signer_sha256_mismatch',
COMPROMISED_SIGNER_SHA256,
'release_signer_sha256',
'_compromised',
'--self-test',
'assertArchivePathStable',
'artifact_hardlink',
]) {
assert(artifactVerifier.includes(required), `artifact_verifier_contract_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
}
assert(!artifactVerifier.includes("['expected-cert-sha256']"), 'artifact_verifier_legacy_certificate_argument')
const appLinksVerifier = readWorkspaceFile('scripts/ci/verify-android-app-links.mjs')
for (const required of [
'expected-play-app-signing-cert-sha256',
'forbidden-upload-cert-sha256',
COMPROMISED_SIGNER_SHA256,
'expectedPlayAppSigningCertificateVerified',
'uploadCertificateExcluded',
'--self-test',
]) {
assert(appLinksVerifier.includes(required), `app_links_verifier_contract_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
}
const siteConfig = readWorkspaceFile('site/vite.config.ts')
assert(siteConfig.includes('publicDir: false'), 'site_public_directory_not_isolated')
assert(!siteConfig.includes("'releases/"), 'site_release_binary_allowlisted')
for (const safePublicPath of [
'site/public/.well-known/assetlinks.json',
'site/public/accept-invite.css',
'site/public/accept-invite.html',
'site/public/accept-invite.js',
'site/public/accept-invite/index.html',
'site/public/download.html',
'site/public/favicon.svg',
]) {
const safePublicSource = readWorkspaceFile(safePublicPath)
assert(!/(?:d3ro-voice[^"']*\.apk|git\.chanpaca\.net\/attachments\/(?:0b015367-dd8b-488c-8cc0-4db413b51792|d2e1b123-5678-496a-bf74-bc188938c999))/i.test(safePublicSource), `safe_public_mobile_reference_${safePublicPath}`)
}
const siteDeployWorkflow = readWorkspaceFile('.github/workflows/deploy-site.yml')
assert(siteDeployWorkflow.includes('verify-mobile-release-boundary.mjs --self-test'), 'pages_deploy_boundary_gate_missing')
const deploySite = readWorkspaceFile('scripts/deploy-site-to-nas.js')
assert(deploySite.includes('assertNoMobileArtifacts'), 'nas_mobile_artifact_guard_missing')
assert(!deploySite.includes('sync-and-publish-forgejo-release'), 'nas_legacy_release_sync_enabled')
const apiProject = readWorkspaceFile('apps/api-server/D3ROVoice.Api.csproj')
assert(apiProject.includes('<Content Remove="wwwroot\\releases\\**\\*" />'), 'api_static_release_exclusion_missing')
for (const staleAsset of ['index-D7M5UQvT.js', 'index-JlYFxlAJ.js']) {
assert(apiProject.includes(`<Content Remove="wwwroot\\assets\\${staleAsset}" />`), `api_stale_marketing_asset_publishable_${staleAsset}`)
}
const apiProgram = readWorkspaceFile('apps/api-server/Program.cs')
assert(apiProgram.includes('mobileReleasePath') && apiProgram.indexOf('mobileReleasePath') < apiProgram.indexOf('app.UseStaticFiles()'), 'api_runtime_mobile_release_guard_missing')
assert(apiProgram.includes('legacyMarketingAsset'), 'api_runtime_legacy_marketing_guard_missing')
const osHook = readWorkspaceFile('site/src/hooks/useClientOS.ts')
const androidConfig = osHook.match(/android:\s*\{[\s\S]*?\n\s*\},/)?.[0] ?? ''
assert(androidConfig.includes("downloadUrl: '#download'"), 'android_download_not_unavailable')
assert(!androidConfig.includes('attachments/'), 'android_attachment_link_enabled')
const downloadUi = readWorkspaceFile('site/src/sections/Download.tsx')
assert(downloadUi.includes('aria-disabled="true"'), 'android_download_ui_not_disabled')
assert(!/attachments\/[0-9a-f-]+[\s\S]{0,120}\.apk/i.test(downloadUi), 'android_direct_attachment_enabled')
}
function expectFailure(label, operation, expectedCode) {
let thrown = null
try {
operation()
} catch (error) {
thrown = error
}
assert(thrown instanceof Error, `${label}_did_not_fail`)
assert(thrown.message.includes(expectedCode), `${label}_wrong_failure_${thrown.message}`)
}
function createFixture(root, privateKeyPem) {
const sourceRoot = join(root, 'source')
const apkDirectory = join(sourceRoot, 'apk', 'release')
const aabDirectory = join(sourceRoot, 'bundle', 'release')
mkdirSync(apkDirectory, { recursive: true })
mkdirSync(aabDirectory, { recursive: true })
const apkPath = join(apkDirectory, RELEASE_APK_NAME)
const aabPath = join(aabDirectory, RELEASE_AAB_NAME)
writeFileSync(apkPath, Buffer.from('fixture production apk\n'.repeat(80)))
writeFileSync(aabPath, Buffer.from('fixture production aab\n'.repeat(90)))
const apk = hashRegularFileStable(apkPath)
const aab = hashRegularFileStable(aabPath)
const verification = {
artifact: RELEASE_APK_NAME,
mode: 'release',
packageName: RELEASE_PACKAGE_NAME,
buildTools: 'fixture',
apkSha256: apk.sha256,
signerSha256: productionSigner,
debuggable: false,
adMobAppId: productionAdMobId,
versionName,
versionCode,
bundleBytes: 123_456,
modelBytes: 77_691_713,
modelSha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21',
abis: ['arm64-v8a'],
aab: {
artifact: RELEASE_AAB_NAME,
sha256: aab.sha256,
packageName: RELEASE_PACKAGE_NAME,
signerSha256: productionSigner,
debuggable: false,
adMobAppId: productionAdMobId,
versionName,
versionCode,
bundleBytes: 123_456,
modelBytes: 77_691_713,
modelSha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21',
abis: ['arm64-v8a'],
},
}
const expected = {
versionName,
versionCode,
adMobAppId: productionAdMobId,
signerSha256: productionSigner,
}
const provenance = structuredClone(releaseProvenance)
expected.provenance = structuredClone(provenance)
const payload = buildReleasePayload({ verification, apkPath, aabPath, expected, provenance })
const evidence = signReleaseEvidence(payload, privateKeyPem)
const evidencePath = join(sourceRoot, SIGNED_EVIDENCE_NAME)
writeJsonCreateOnly(evidencePath, evidence)
return {
sourceRoot,
apkPath,
aabPath,
evidencePath,
evidence,
payload,
expected,
provenance,
verification,
}
}
function verifyNegativeAndMaterializationTests() {
const temporaryRoot = mkdtempSync(join(tmpdir(), 'd3ro-mobile-release-boundary-'))
const trustedTempRoot = realpathSync(tmpdir())
const resolvedTemporaryRoot = realpathSync(temporaryRoot)
assert(
resolvedTemporaryRoot.startsWith(`${trustedTempRoot}${sep}`),
'temporary_root_outside_system_temp',
)
try {
const creatorOptions = {
aab: 'fixture',
apk: 'fixture',
bundletool: 'fixture',
'commit-sha': releaseProvenance.commitSha,
'expected-admob-app-id': productionAdMobId,
'expected-upload-cert-sha256': productionSigner,
'expected-version-code': versionCode,
'expected-version-name': versionName,
'git-ref': releaseProvenance.gitRef,
'private-key': 'fixture',
repository: releaseProvenance.repository,
'run-attempt': releaseProvenance.runAttempt,
'run-id': releaseProvenance.runId,
'runner-identity': releaseProvenance.runnerIdentity,
'snapshot-dir': 'fixture',
'tree-sha': releaseProvenance.treeSha,
'workflow-identity': releaseProvenance.workflowIdentity,
}
for (const name of [
'repository',
'commit-sha',
'tree-sha',
'git-ref',
'workflow-identity',
'run-id',
'run-attempt',
'runner-identity',
]) {
runCliExpectedFailure(
'scripts/ci/create-mobile-release-evidence.mjs',
creatorOptions,
name,
`create_evidence_argument_missing_${name}`,
)
}
const publicationOptions = {
aab: 'fixture',
apk: 'fixture',
'destination-dir': 'fixture',
evidence: 'fixture',
'expected-admob-app-id': productionAdMobId,
'expected-bundletool-sha256': releaseProvenance.bundletoolSha256,
'expected-commit-sha': releaseProvenance.commitSha,
'expected-git-ref': releaseProvenance.gitRef,
'expected-repository': releaseProvenance.repository,
'expected-run-attempt': releaseProvenance.runAttempt,
'expected-run-id': releaseProvenance.runId,
'expected-runner-identity': releaseProvenance.runnerIdentity,
'expected-tree-sha': releaseProvenance.treeSha,
'expected-upload-cert-sha256': productionSigner,
'expected-verifier-sha256': releaseProvenance.verifierSha256,
'expected-version-code': versionCode,
'expected-version-name': versionName,
'expected-workflow-identity': releaseProvenance.workflowIdentity,
'public-key': 'fixture',
'source-root': 'fixture',
}
for (const name of [
'expected-repository',
'expected-commit-sha',
'expected-tree-sha',
'expected-git-ref',
'expected-workflow-identity',
'expected-run-id',
'expected-run-attempt',
'expected-runner-identity',
'expected-verifier-sha256',
'expected-bundletool-sha256',
]) {
runCliExpectedFailure(
'scripts/ci/prepare-mobile-release-publication.mjs',
publicationOptions,
name,
`prepare_publication_argument_missing_${name}`,
)
}
runVerifierSelfTest('scripts/ci/verify-android-artifact.mjs')
runVerifierSelfTest('scripts/ci/verify-android-app-links.mjs')
const keyPair = generateKeyPairSync('ed25519')
const otherKeyPair = generateKeyPairSync('ed25519')
const privateKeyPem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' })
const publicKeyPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' })
const publicKeyPath = join(temporaryRoot, 'trusted-public.pem')
const otherPublicKeyPath = join(temporaryRoot, 'other-public.pem')
writeFileSync(publicKeyPath, publicKeyPem, { flag: 'wx', mode: 0o600 })
writeFileSync(
otherPublicKeyPath,
otherKeyPair.publicKey.export({ type: 'spki', format: 'pem' }),
{ flag: 'wx', mode: 0o600 },
)
const fixture = createFixture(temporaryRoot, privateKeyPem)
const rawSnapshotSource = join(temporaryRoot, 'raw-snapshot-source')
mkdirSync(rawSnapshotSource)
const rawApk = join(rawSnapshotSource, RELEASE_APK_NAME)
const rawAab = join(rawSnapshotSource, RELEASE_AAB_NAME)
const rawVerifier = join(rawSnapshotSource, 'verify-android-artifact.mjs')
const rawBundletool = join(rawSnapshotSource, 'bundletool-fixture.jar')
writeFileSync(rawApk, 'raw apk snapshot fixture')
writeFileSync(rawAab, 'raw aab snapshot fixture')
writeFileSync(rawVerifier, 'raw verifier snapshot fixture')
writeFileSync(rawBundletool, 'raw bundletool snapshot fixture')
const immutableSnapshot = createImmutableVerificationSnapshot({
apkPath: rawApk,
aabPath: rawAab,
destinationDirectory: join(temporaryRoot, 'immutable-verification-snapshot'),
verifierPath: rawVerifier,
bundletoolPath: rawBundletool,
})
assert(hashRegularFileStable(immutableSnapshot.apkPath).sha256 === hashRegularFileStable(rawApk).sha256, 'immutable_apk_snapshot_mismatch')
assert(hashRegularFileStable(immutableSnapshot.aabPath).sha256 === hashRegularFileStable(rawAab).sha256, 'immutable_aab_snapshot_mismatch')
assert(hashRegularFileStable(immutableSnapshot.verifierPath).sha256 === hashRegularFileStable(rawVerifier).sha256, 'immutable_verifier_snapshot_mismatch')
assert(hashRegularFileStable(immutableSnapshot.bundletoolPath).sha256 === hashRegularFileStable(rawBundletool).sha256, 'immutable_bundletool_snapshot_mismatch')
const destination = join(temporaryRoot, 'sealed-release')
const prepared = prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: destination,
})
assert(prepared.manifest.packageName === RELEASE_PACKAGE_NAME, 'happy_package_mismatch')
assert(existsSync(join(destination, RELEASE_APK_NAME)), 'happy_apk_missing')
assert(existsSync(join(destination, RELEASE_AAB_NAME)), 'happy_aab_missing')
expectFailure('overwrite', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: destination,
}), 'destination_must_not_exist')
expectFailure('wrong_key', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath: otherPublicKeyPath,
destinationDirectory: join(temporaryRoot, 'wrong-key-output'),
}), 'signature_key_mismatch')
expectFailure('wrong_version', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'wrong-version-output'),
expected: { ...fixture.expected, versionName: '9.8.8' },
}), 'publication_version_name_mismatch')
expectFailure('wrong_certificate', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'wrong-certificate-output'),
expected: { ...fixture.expected, signerSha256: '1'.repeat(64) },
}), 'publication_signer_mismatch')
expectFailure('compromised_expected_publication_certificate', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'compromised-certificate-output'),
expected: { ...fixture.expected, signerSha256: COMPROMISED_SIGNER_SHA256 },
}), 'publication_expected_compromised_signer')
expectFailure('wrong_admob', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'wrong-admob-output'),
expected: { ...fixture.expected, adMobAppId: 'ca-app-pub-1234567890123456~1234567891' },
}), 'publication_admob_app_id_mismatch')
for (const [field, mismatch, code] of [
['repository', 'attacker/D3ROVoice', 'repository'],
['commitSha', 'e'.repeat(40), 'commit_sha'],
['treeSha', 'f'.repeat(40), 'tree_sha'],
['gitRef', 'refs/tags/v9.8.8', 'git_ref'],
['workflowIdentity', 'github:.github/workflows/release.yml:attacker', 'workflow_identity'],
['runId', '987654321', 'run_id'],
['runAttempt', 2, 'run_attempt'],
['runnerIdentity', 'self-hosted:attacker', 'runner_identity'],
['verifierSha256', '1'.repeat(64), 'verifier_sha256'],
['bundletoolSha256', '2'.repeat(64), 'bundletool_sha256'],
]) {
expectFailure(`wrong_provenance_${code}`, () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, `wrong-provenance-${code}-output`),
expected: {
...fixture.expected,
provenance: { ...fixture.expected.provenance, [field]: mismatch },
},
}), `publication_provenance_${code}_mismatch`)
}
const missingProvenanceField = structuredClone(fixture.payload)
delete missingProvenanceField.provenance.treeSha
expectFailure('missing_provenance_field', () => signReleaseEvidence(
missingProvenanceField,
privateKeyPem,
), 'provenance_keys_invalid')
expectFailure('extra_provenance_field', () => signReleaseEvidence({
...fixture.payload,
provenance: { ...fixture.payload.provenance, untrusted: 'extra' },
}, privateKeyPem), 'provenance_keys_invalid')
expectFailure('noncanonical_run_attempt', () => signReleaseEvidence({
...fixture.payload,
provenance: { ...fixture.payload.provenance, runAttempt: '01' },
}, privateKeyPem), 'provenance_run_attempt_invalid')
const missingExpectedProvenanceField = structuredClone(fixture.expected.provenance)
delete missingExpectedProvenanceField.runnerIdentity
expectFailure('missing_expected_provenance_field', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'missing-expected-provenance-output'),
expected: { ...fixture.expected, provenance: missingExpectedProvenanceField },
}), 'expected_provenance_keys_invalid')
const legacySchemaFixture = createFixture(join(temporaryRoot, 'legacy-schema-case'), privateKeyPem)
const legacySchemaEvidence = { ...legacySchemaFixture.evidence, schemaVersion: 1 }
writeFileSync(
legacySchemaFixture.evidencePath,
`${JSON.stringify(legacySchemaEvidence)}\n`,
{ flag: 'w', mode: 0o600 },
)
expectFailure('legacy_evidence_schema', () => prepareVerifiedReleasePublication({
...legacySchemaFixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'legacy-schema-output'),
}), 'evidence_schema_version')
const tamperedFixture = createFixture(join(temporaryRoot, 'tampered-case'), privateKeyPem)
const tamperedEvidence = structuredClone(tamperedFixture.evidence)
tamperedEvidence.payload.versionCode += 1
writeFileSync(
tamperedFixture.evidencePath,
`${JSON.stringify(tamperedEvidence)}\n`,
{ flag: 'w', mode: 0o600 },
)
expectFailure('tampered_signature', () => prepareVerifiedReleasePublication({
...tamperedFixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'tampered-output'),
}), 'signature_invalid')
const provenanceTamperedFixture = createFixture(
join(temporaryRoot, 'provenance-tampered-case'),
privateKeyPem,
)
const provenanceTamperedEvidence = structuredClone(provenanceTamperedFixture.evidence)
provenanceTamperedEvidence.payload.provenance.commitSha = 'e'.repeat(40)
writeFileSync(
provenanceTamperedFixture.evidencePath,
`${JSON.stringify(provenanceTamperedEvidence)}\n`,
{ flag: 'w', mode: 0o600 },
)
expectFailure('tampered_provenance_signature', () => prepareVerifiedReleasePublication({
...provenanceTamperedFixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'provenance-tampered-output'),
}), 'signature_invalid')
expectFailure('nonrelease_mode', () => signReleaseEvidence({
...fixture.payload,
mode: 'e2e',
}, privateKeyPem), 'nonrelease_mode_rejected')
expectFailure('debuggable', () => signReleaseEvidence({
...fixture.payload,
debuggable: true,
}, privateKeyPem), 'debuggable_release_rejected')
expectFailure('test_admob', () => signReleaseEvidence({
...fixture.payload,
adMobAppId: 'ca-app-pub-3940256099942544~3347511713',
}, privateKeyPem), 'test_admob_rejected')
expectFailure('debug_signer', () => signReleaseEvidence({
...fixture.payload,
signerSha256: 'fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c',
}, privateKeyPem), 'debug_signer_rejected')
expectFailure('compromised_signer', () => signReleaseEvidence({
...fixture.payload,
signerSha256: COMPROMISED_SIGNER_SHA256,
}, privateKeyPem), 'compromised_signer_rejected')
expectFailure('package_name', () => signReleaseEvidence({
...fixture.payload,
packageName: 'com.attacker.voice',
}, privateKeyPem), 'package_name_mismatch')
expectFailure('path_escape_name', () => signReleaseEvidence({
...fixture.payload,
apk: { ...fixture.payload.apk, fileName: '../app-release.apk' },
}, privateKeyPem), 'apk_file_name_invalid')
for (const [label, patch, code] of [
['aab_package', { packageName: 'com.attacker.voice' }, 'verification_aab_package_mismatch'],
['aab_version_name', { versionName: '9.8.8' }, 'aab_apk_version_name_mismatch'],
['aab_version_code', { versionCode: versionCode + 1 }, 'aab_apk_version_code_mismatch'],
['aab_admob', { adMobAppId: 'ca-app-pub-1234567890123456~1234567891' }, 'aab_apk_admob_app_id_mismatch'],
['aab_signer', { signerSha256: '1'.repeat(64) }, 'aab_apk_signer_mismatch'],
['aab_compromised_signer', { signerSha256: COMPROMISED_SIGNER_SHA256 }, 'verification_aab_compromised_signer'],
['aab_debuggable', { debuggable: true }, 'verification_aab_debuggable'],
]) {
expectFailure(label, () => buildReleasePayload({
verification: { ...fixture.verification, aab: { ...fixture.verification.aab, ...patch } },
apkPath: fixture.apkPath,
aabPath: fixture.aabPath,
expected: fixture.expected,
provenance: fixture.provenance,
}), code)
}
expectFailure('verification_compromised_signer', () => buildReleasePayload({
verification: { ...fixture.verification, signerSha256: COMPROMISED_SIGNER_SHA256 },
apkPath: fixture.apkPath,
aabPath: fixture.aabPath,
expected: fixture.expected,
provenance: fixture.provenance,
}), 'verification_compromised_signer')
expectFailure('expected_compromised_signer', () => buildReleasePayload({
verification: fixture.verification,
apkPath: fixture.apkPath,
aabPath: fixture.aabPath,
expected: { ...fixture.expected, signerSha256: COMPROMISED_SIGNER_SHA256 },
provenance: fixture.provenance,
}), 'expected_compromised_signer')
const hardlinkPath = join(temporaryRoot, 'hardlinked.apk')
linkSync(fixture.apkPath, hardlinkPath)
expectFailure('hardlink_source', () => hashRegularFileStable(hardlinkPath), 'hardlink_rejected')
rmSync(hardlinkPath)
const unsafeSite = join(temporaryRoot, 'unsafe-site')
mkdirSync(unsafeSite)
writeFileSync(join(unsafeSite, 'legacy.js'), 'location.href="https://git.chanpaca.net/attachments/0b015367-dd8b-488c-8cc0-4db413b51792"')
expectFailure('static_link_bypass', () => assertNoMobileArtifacts(unsafeSite), 'Legacy mobile download link blocked')
rmSync(join(unsafeSite, 'legacy.js'))
writeFileSync(join(unsafeSite, 'unsealed.apk'), 'not a release')
expectFailure('static_apk_bypass', () => assertNoMobileArtifacts(unsafeSite), 'Unsealed mobile artifact blocked')
const outsideRoot = join(temporaryRoot, 'outside')
mkdirSync(outsideRoot)
const outsideApk = join(outsideRoot, RELEASE_APK_NAME)
writeFileSync(outsideApk, 'outside')
expectFailure('outside_source_root', () => prepareVerifiedReleasePublication({
...fixture,
apkPath: outsideApk,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'outside-output'),
}), `${RELEASE_APK_NAME}_outside_source_root`)
const swappedOriginal = `${fixture.apkPath}.original`
renameSync(fixture.apkPath, swappedOriginal)
writeFileSync(fixture.apkPath, Buffer.alloc(fixture.payload.apk.bytes, 0x58))
expectFailure('artifact_path_swap', () => prepareVerifiedReleasePublication({
...fixture,
publicKeyPath,
destinationDirectory: join(temporaryRoot, 'hash-output'),
}), `${RELEASE_APK_NAME}_hash_mismatch`)
assert(!existsSync(join(temporaryRoot, 'hash-output')), 'failed_snapshot_destination_not_cleaned')
expectFailure('verification_nonrelease', () => buildReleasePayload({
verification: { ...fixture.verification, mode: 'e2e' },
apkPath: fixture.apkPath,
aabPath: fixture.aabPath,
expected: fixture.expected,
provenance: fixture.provenance,
}), 'verification_nonrelease_mode')
const createOnlyPath = join(temporaryRoot, 'create-only.json')
writeJsonCreateOnly(createOnlyPath, { first: true })
expectFailure('create_only_evidence', () => writeJsonCreateOnly(createOnlyPath, { second: true }), 'EEXIST')
} finally {
const finalRoot = realpathSync(temporaryRoot)
if (!finalRoot.startsWith(`${trustedTempRoot}${sep}`)) fail('cleanup_target_outside_temp')
rmSync(finalRoot, { recursive: true, force: false })
}
}
const cliArguments = process.argv.slice(2)
const selfTest = cliArguments.includes('--self-test')
if (cliArguments.some((argument) => argument !== '--self-test')) fail('unexpected_argument')
verifySourceContracts()
if (selfTest) verifyNegativeAndMaterializationTests()
process.stdout.write(`${canonicalJson({
ok: true,
sourceContracts: true,
negativeSelfTests: selfTest,
})}\n`)

View file

@ -0,0 +1,403 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(import.meta.dirname, '../..')
const DEFAULT_GOOGLE_SERVICES_PATH = resolve(
ROOT,
'apps/mobile-rn/android/app/google-services.json',
)
const PRODUCTION_PACKAGE_NAME = 'com.d3ro.voice'
const GOOGLE_TEST_ADMOB_PUBLISHER_ID = '3940256099942544'
const FIREBASE_PROJECT_ID = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const FIREBASE_PROJECT_NUMBER = /^[1-9][0-9]{5,19}$/
const FIREBASE_MOBILESDK_APP_ID = /^1:([1-9][0-9]{5,19}):android:([0-9a-f]{16,64})$/
const ANDROID_PACKAGE_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/i
const GOOGLE_API_KEY = /^AIza[0-9A-Za-z_-]{20,96}$/
const ADMOB_APP_ID = /^ca-app-pub-([0-9]{16})~([0-9]{10})$/
const ADMOB_UNIT_ID = /^ca-app-pub-([0-9]{16})\/([0-9]{10})$/
const MAX_GOOGLE_SERVICES_BYTES = 1024 * 1024
function fail(code) {
throw new Error(`mobile_release_config_invalid:${code}`)
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function requireExactString(value, code) {
if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) fail(code)
return value
}
function requireExpectedFirebaseIdentity(expected) {
if (!isRecord(expected)) fail('firebase_expected_identity_missing')
const projectId = requireExactString(
expected.projectId,
'firebase_expected_project_id_missing',
)
const projectNumber = requireExactString(
expected.projectNumber,
'firebase_expected_project_number_missing',
)
const mobileSdkAppId = requireExactString(
expected.mobileSdkAppId,
'firebase_expected_mobilesdk_app_id_missing',
)
if (!FIREBASE_PROJECT_ID.test(projectId)) fail('firebase_expected_project_id_format')
if (!FIREBASE_PROJECT_NUMBER.test(projectNumber)) {
fail('firebase_expected_project_number_format')
}
const appIdMatch = mobileSdkAppId.match(FIREBASE_MOBILESDK_APP_ID)
if (!appIdMatch) fail('firebase_expected_mobilesdk_app_id_format')
if (appIdMatch[1] !== projectNumber) {
fail('firebase_expected_mobilesdk_app_id_project_number_mismatch')
}
return { projectId, projectNumber, mobileSdkAppId }
}
export function parseGoogleServicesSource(source) {
if (typeof source !== 'string' || source.length === 0) fail('google_services_empty')
if (Buffer.byteLength(source, 'utf8') > MAX_GOOGLE_SERVICES_BYTES) {
fail('google_services_too_large')
}
try {
return JSON.parse(source)
} catch {
fail('google_services_invalid_json')
}
}
export function verifyFirebaseConfiguration(configuration, expectedIdentity) {
const expected = requireExpectedFirebaseIdentity(expectedIdentity)
if (!isRecord(configuration)) fail('google_services_root_invalid')
if (configuration.configuration_version !== '1') {
fail('google_services_configuration_version_invalid')
}
const projectInfo = configuration.project_info
if (!isRecord(projectInfo)) fail('firebase_project_info_missing')
const projectId = requireExactString(projectInfo.project_id, 'firebase_project_id_missing')
const projectNumber = requireExactString(
projectInfo.project_number,
'firebase_project_number_missing',
)
if (!FIREBASE_PROJECT_ID.test(projectId)) fail('firebase_project_id_format')
if (!FIREBASE_PROJECT_NUMBER.test(projectNumber)) fail('firebase_project_number_format')
if (projectId !== expected.projectId) fail('firebase_project_id_mismatch')
if (projectNumber !== expected.projectNumber) fail('firebase_project_number_mismatch')
const clients = configuration.client
if (!Array.isArray(clients) || clients.length === 0) fail('firebase_clients_missing')
if (clients.length > 100) fail('firebase_client_count_excessive')
const packages = new Set()
const appIds = new Set()
let productionClient
for (const client of clients) {
if (!isRecord(client)) fail('firebase_client_invalid')
const clientInfo = client.client_info
if (!isRecord(clientInfo)) fail('firebase_client_info_missing')
const androidClientInfo = clientInfo.android_client_info
if (!isRecord(androidClientInfo)) fail('firebase_android_client_info_missing')
const packageName = requireExactString(
androidClientInfo.package_name,
'firebase_client_package_missing',
)
const mobileSdkAppId = requireExactString(
clientInfo.mobilesdk_app_id,
'firebase_client_mobilesdk_app_id_missing',
)
if (!ANDROID_PACKAGE_NAME.test(packageName)) fail('firebase_client_package_format')
const appIdMatch = mobileSdkAppId.match(FIREBASE_MOBILESDK_APP_ID)
if (!appIdMatch) fail('firebase_client_mobilesdk_app_id_format')
if (appIdMatch[1] !== projectNumber) {
fail('firebase_client_mobilesdk_app_id_project_number_mismatch')
}
if (packages.has(packageName)) fail('firebase_client_package_duplicate')
if (appIds.has(mobileSdkAppId)) fail('firebase_client_mobilesdk_app_id_duplicate')
packages.add(packageName)
appIds.add(mobileSdkAppId)
if (packageName === PRODUCTION_PACKAGE_NAME) productionClient = client
}
if (!productionClient) fail('firebase_production_client_missing')
if (productionClient.client_info.mobilesdk_app_id !== expected.mobileSdkAppId) {
fail('firebase_mobilesdk_app_id_mismatch')
}
const apiKeys = productionClient.api_key
if (!Array.isArray(apiKeys) || apiKeys.length !== 1) {
fail('firebase_production_api_key_must_be_unique')
}
if (!isRecord(apiKeys[0])) fail('firebase_production_api_key_invalid')
const apiKey = requireExactString(
apiKeys[0].current_key,
'firebase_production_api_key_missing',
)
if (!GOOGLE_API_KEY.test(apiKey)) fail('firebase_production_api_key_format')
return Object.freeze({
packageNameVerified: true,
projectIdentityVerified: true,
clientIdentityUnique: true,
apiKeyUnique: true,
})
}
function parseAdMobId(value, expression, missingCode, formatCode) {
const normalized = requireExactString(value, missingCode)
const match = normalized.match(expression)
if (!match) fail(formatCode)
if (/^0+$/.test(match[1]) || /^0+$/.test(match[2])) fail(formatCode)
return { value: normalized, publisherId: match[1] }
}
export function verifyAdMobConfiguration(configuration) {
if (!isRecord(configuration)) fail('admob_configuration_missing')
const app = parseAdMobId(
configuration.appId,
ADMOB_APP_ID,
'admob_app_id_missing',
'admob_app_id_format',
)
const banner = parseAdMobId(
configuration.bannerUnitId,
ADMOB_UNIT_ID,
'admob_banner_unit_id_missing',
'admob_banner_unit_id_format',
)
const rewarded = parseAdMobId(
configuration.rewardedUnitId,
ADMOB_UNIT_ID,
'admob_rewarded_unit_id_missing',
'admob_rewarded_unit_id_format',
)
for (const entry of [app, banner, rewarded]) {
if (entry.publisherId === GOOGLE_TEST_ADMOB_PUBLISHER_ID) {
fail('admob_google_test_publisher_rejected')
}
}
if (app.publisherId !== banner.publisherId || app.publisherId !== rewarded.publisherId) {
fail('admob_publisher_mismatch')
}
if (banner.value === rewarded.value) fail('admob_unit_ids_must_be_unique')
return Object.freeze({
productionFormatVerified: true,
googleTestPublisherRejected: true,
publisherConsistencyVerified: true,
unitIdentityUnique: true,
})
}
function clone(value) {
return structuredClone(value)
}
function expectFailure(label, action, expectedCode) {
try {
action()
} catch (error) {
const expectedMessage = `mobile_release_config_invalid:${expectedCode}`
if (error instanceof Error && error.message === expectedMessage) return
throw new Error(`mobile_release_config_self_test_unexpected:${label}`)
}
throw new Error(`mobile_release_config_self_test_missed:${label}`)
}
function makeFirebaseFixture() {
return {
configuration_version: '1',
project_info: {
project_number: '123456789012',
project_id: 'd3ro-production',
storage_bucket: 'd3ro-production.example.invalid',
},
client: [
{
client_info: {
mobilesdk_app_id: '1:123456789012:android:0123456789abcdef',
android_client_info: { package_name: PRODUCTION_PACKAGE_NAME },
},
api_key: [
{ current_key: ['AI', 'zaFixtureKeyMaterial1234567890abcd'].join('') },
],
},
],
}
}
function runSelfTest() {
const expected = {
projectId: 'd3ro-production',
projectNumber: '123456789012',
mobileSdkAppId: '1:123456789012:android:0123456789abcdef',
}
const firebase = makeFirebaseFixture()
const admob = {
appId: 'ca-app-pub-1234567890123456~1234567890',
bannerUnitId: 'ca-app-pub-1234567890123456/2345678901',
rewardedUnitId: 'ca-app-pub-1234567890123456/3456789012',
}
verifyFirebaseConfiguration(firebase, expected)
verifyAdMobConfiguration(admob)
parseGoogleServicesSource(JSON.stringify(firebase))
const firebaseCases = [
['invalid_json', () => parseGoogleServicesSource('{'), 'google_services_invalid_json'],
['root_array', () => verifyFirebaseConfiguration([], expected), 'google_services_root_invalid'],
['configuration_version', () => {
const value = clone(firebase)
value.configuration_version = '2'
return verifyFirebaseConfiguration(value, expected)
}, 'google_services_configuration_version_invalid'],
['project_id_mismatch', () => {
const value = clone(firebase)
value.project_info.project_id = 'different-production'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_project_id_mismatch'],
['project_number_mismatch', () => {
const value = clone(firebase)
value.project_info.project_number = '223456789012'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_project_number_mismatch'],
['missing_clients', () => {
const value = clone(firebase)
value.client = []
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_clients_missing'],
['duplicate_package', () => {
const value = clone(firebase)
const duplicate = clone(value.client[0])
duplicate.client_info.mobilesdk_app_id = '1:123456789012:android:fedcba9876543210'
value.client.push(duplicate)
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_client_package_duplicate'],
['duplicate_app_id', () => {
const value = clone(firebase)
const duplicate = clone(value.client[0])
duplicate.client_info.android_client_info.package_name = 'com.d3ro.voice.other'
value.client.push(duplicate)
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_client_mobilesdk_app_id_duplicate'],
['production_package_missing', () => {
const value = clone(firebase)
value.client[0].client_info.android_client_info.package_name = 'com.d3ro.voice.other'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_production_client_missing'],
['app_id_mismatch', () => {
const value = clone(firebase)
value.client[0].client_info.mobilesdk_app_id = '1:123456789012:android:fedcba9876543210'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_mobilesdk_app_id_mismatch'],
['app_id_project_number_mismatch', () => {
const value = clone(firebase)
value.client[0].client_info.mobilesdk_app_id = '1:223456789012:android:0123456789abcdef'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_client_mobilesdk_app_id_project_number_mismatch'],
['api_key_missing', () => {
const value = clone(firebase)
value.client[0].api_key = []
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_production_api_key_must_be_unique'],
['api_key_ambiguous', () => {
const value = clone(firebase)
value.client[0].api_key.push(clone(value.client[0].api_key[0]))
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_production_api_key_must_be_unique'],
['expected_identity_missing', () => verifyFirebaseConfiguration(firebase, {}), 'firebase_expected_project_id_missing'],
['expected_app_id_project_number_mismatch', () => verifyFirebaseConfiguration(firebase, {
...expected,
mobileSdkAppId: '1:223456789012:android:0123456789abcdef',
}), 'firebase_expected_mobilesdk_app_id_project_number_mismatch'],
]
for (const [label, action, expectedCode] of firebaseCases) {
expectFailure(label, action, expectedCode)
}
const admobCases = [
['app_format', { ...admob, appId: 'ca-app-pub-123~456' }, 'admob_app_id_format'],
['banner_format', { ...admob, bannerUnitId: 'ca-app-pub-1234567890123456~2345678901' }, 'admob_banner_unit_id_format'],
['rewarded_format', { ...admob, rewardedUnitId: 'ca-app-pub-1234567890123456/123' }, 'admob_rewarded_unit_id_format'],
['app_test_publisher', {
appId: 'ca-app-pub-3940256099942544~3347511713',
bannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
rewardedUnitId: 'ca-app-pub-3940256099942544/5224354917',
}, 'admob_google_test_publisher_rejected'],
['banner_test_publisher', {
...admob,
bannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
}, 'admob_google_test_publisher_rejected'],
['publisher_mismatch', {
...admob,
rewardedUnitId: 'ca-app-pub-2234567890123456/3456789012',
}, 'admob_publisher_mismatch'],
['unit_duplicate', { ...admob, rewardedUnitId: admob.bannerUnitId }, 'admob_unit_ids_must_be_unique'],
['whitespace', { ...admob, appId: `${admob.appId} ` }, 'admob_app_id_missing'],
['zero_identifier', {
...admob,
bannerUnitId: 'ca-app-pub-1234567890123456/0000000000',
}, 'admob_banner_unit_id_format'],
]
for (const [label, configuration, expectedCode] of admobCases) {
expectFailure(label, () => verifyAdMobConfiguration(configuration), expectedCode)
}
return { firebaseFailureCases: firebaseCases.length, adMobFailureCases: admobCases.length }
}
async function run() {
if (process.argv.includes('--self-test')) {
const result = runSelfTest()
console.log(JSON.stringify({ selfTest: 'verified', ...result }))
return
}
if (process.argv.length > 2) fail('unknown_argument')
let source
try {
source = await readFile(
process.env.D3RO_GOOGLE_SERVICES_JSON_FILE || DEFAULT_GOOGLE_SERVICES_PATH,
'utf8',
)
} catch {
fail('google_services_file_unreadable')
}
const firebase = verifyFirebaseConfiguration(parseGoogleServicesSource(source), {
projectId: process.env.D3RO_FIREBASE_EXPECTED_PROJECT_ID,
projectNumber: process.env.D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER,
mobileSdkAppId: process.env.D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID,
})
const adMob = verifyAdMobConfiguration({
appId: process.env.D3RO_ADMOB_APP_ID,
bannerUnitId: process.env.D3RO_ADMOB_BANNER_UNIT_ID,
rewardedUnitId: process.env.D3RO_ADMOB_REWARDED_UNIT_ID,
})
console.log(JSON.stringify({
releaseConfiguration: 'verified',
firebase,
adMob,
}))
}
const isMain = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
if (isMain) {
run().catch((error) => {
console.error(error instanceof Error ? error.message : 'mobile_release_config_invalid:unknown')
process.exitCode = 1
})
}

View file

@ -0,0 +1,710 @@
import { createHash } from 'node:crypto'
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { inflateSync } from 'node:zlib'
const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
const featureAssetDirectoryRelative = 'docs/v3/play/assets/feature-graphic'
const mobileIconManifestRelative = 'docs/v3/play/assets/mobile-icon-manifest.json'
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const androidLegacyDensities = new Map([
['mdpi', 48],
['hdpi', 72],
['xhdpi', 96],
['xxhdpi', 144],
['xxxhdpi', 192]
])
const requiredIosSlots = [
['iphone', '20x20', '2x', 'AppIcon-iphone-20@2x.png', 40],
['iphone', '20x20', '3x', 'AppIcon-iphone-20@3x.png', 60],
['iphone', '29x29', '2x', 'AppIcon-iphone-29@2x.png', 58],
['iphone', '29x29', '3x', 'AppIcon-iphone-29@3x.png', 87],
['iphone', '40x40', '2x', 'AppIcon-iphone-40@2x.png', 80],
['iphone', '40x40', '3x', 'AppIcon-iphone-40@3x.png', 120],
['iphone', '60x60', '2x', 'AppIcon-iphone-60@2x.png', 120],
['iphone', '60x60', '3x', 'AppIcon-iphone-60@3x.png', 180],
['ipad', '20x20', '1x', 'AppIcon-ipad-20@1x.png', 20],
['ipad', '20x20', '2x', 'AppIcon-ipad-20@2x.png', 40],
['ipad', '29x29', '1x', 'AppIcon-ipad-29@1x.png', 29],
['ipad', '29x29', '2x', 'AppIcon-ipad-29@2x.png', 58],
['ipad', '40x40', '1x', 'AppIcon-ipad-40@1x.png', 40],
['ipad', '40x40', '2x', 'AppIcon-ipad-40@2x.png', 80],
['ipad', '76x76', '1x', 'AppIcon-ipad-76@1x.png', 76],
['ipad', '76x76', '2x', 'AppIcon-ipad-76@2x.png', 152],
['ipad', '83.5x83.5', '2x', 'AppIcon-ipad-83.5@2x.png', 167],
['ios-marketing', '1024x1024', '1x', 'AppIcon-marketing-1024@1x.png', 1024]
]
function fail(code) {
throw new Error(`play_store_asset_verification_failed:${code}`)
}
function assert(condition, code) {
if (!condition) fail(code)
}
function comparablePath(path) {
return process.platform === 'win32' ? path.toLowerCase() : path
}
function resolveInside(baseDirectory, relativePath, code = 'invalid_asset_path') {
assert(typeof relativePath === 'string' && relativePath.length > 0, 'invalid_asset_path')
const absolutePath = resolve(baseDirectory, relativePath)
const comparableBase = comparablePath(baseDirectory)
const comparableAbsolute = comparablePath(absolutePath)
assert(
comparableAbsolute.startsWith(`${comparableBase}${sep}`),
`${code}_outside_allowed_directory`
)
return absolutePath
}
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex').toUpperCase()
}
function readRequired(baseDirectory, relativePath, code) {
const absolutePath = resolveInside(baseDirectory, relativePath, code)
try {
const file = readFileSync(absolutePath)
assert(statSync(absolutePath).isFile(), `${code}_not_file`)
return { absolutePath, file }
} catch (error) {
if (error?.code === 'ENOENT') fail(`${code}_missing`)
throw error
}
}
function readJson(baseDirectory, relativePath, code) {
const { file } = readRequired(baseDirectory, relativePath, code)
try {
return JSON.parse(file.toString('utf8'))
} catch {
fail(`${code}_invalid_json`)
}
}
function readPngHeader(buffer, filename) {
assert(buffer.length >= 33, `truncated_png_${filename}`)
assert(buffer.subarray(0, 8).equals(pngSignature), `invalid_png_signature_${filename}`)
assert(buffer.readUInt32BE(8) === 13, `invalid_ihdr_length_${filename}`)
assert(buffer.subarray(12, 16).toString('ascii') === 'IHDR', `missing_ihdr_${filename}`)
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
bitDepth: buffer[24],
colorType: buffer[25],
compression: buffer[26],
filter: buffer[27],
interlace: buffer[28]
}
}
function parsePng(buffer, filename) {
const header = readPngHeader(buffer, filename)
const idat = []
let hasTransparencyChunk = false
let sawIend = false
let offset = 8
while (offset < buffer.length) {
assert(offset + 12 <= buffer.length, `truncated_png_chunk_${filename}`)
const length = buffer.readUInt32BE(offset)
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii')
const dataStart = offset + 8
const dataEnd = dataStart + length
assert(dataEnd + 4 <= buffer.length, `truncated_png_chunk_${filename}`)
if (type === 'IDAT') idat.push(buffer.subarray(dataStart, dataEnd))
if (type === 'tRNS') hasTransparencyChunk = true
offset = dataEnd + 4
if (type === 'IEND') {
sawIend = true
break
}
}
assert(sawIend, `missing_png_iend_${filename}`)
return { ...header, idat, hasTransparencyChunk }
}
function paethPredictor(left, up, upperLeft) {
const predictor = left + up - upperLeft
const leftDistance = Math.abs(predictor - left)
const upDistance = Math.abs(predictor - up)
const upperLeftDistance = Math.abs(predictor - upperLeft)
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left
if (upDistance <= upperLeftDistance) return up
return upperLeft
}
function rgba8AlphaRange(png, filename) {
assert(png.bitDepth === 8 && png.colorType === 6, `unsupported_alpha_png_${filename}`)
assert(png.compression === 0 && png.filter === 0, `unsupported_png_encoding_${filename}`)
assert(png.interlace === 0, `interlaced_png_${filename}`)
assert(png.idat.length > 0, `missing_png_idat_${filename}`)
const bytesPerPixel = 4
const rowBytes = png.width * bytesPerPixel
const inflated = inflateSync(Buffer.concat(png.idat))
assert(inflated.length === (rowBytes + 1) * png.height, `unexpected_png_data_length_${filename}`)
let minimum = 255
let maximum = 0
let previousRow = Buffer.alloc(rowBytes)
let inputOffset = 0
for (let y = 0; y < png.height; y += 1) {
const filterType = inflated[inputOffset]
inputOffset += 1
assert(filterType <= 4, `invalid_png_filter_${filename}`)
const row = Buffer.allocUnsafe(rowBytes)
for (let x = 0; x < rowBytes; x += 1) {
const raw = inflated[inputOffset + x]
const left = x >= bytesPerPixel ? row[x - bytesPerPixel] : 0
const up = previousRow[x]
const upperLeft = x >= bytesPerPixel ? previousRow[x - bytesPerPixel] : 0
let value = raw
if (filterType === 1) value += left
if (filterType === 2) value += up
if (filterType === 3) value += Math.floor((left + up) / 2)
if (filterType === 4) value += paethPredictor(left, up, upperLeft)
row[x] = value & 0xff
}
for (let x = 3; x < rowBytes; x += bytesPerPixel) {
minimum = Math.min(minimum, row[x])
maximum = Math.max(maximum, row[x])
}
inputOffset += rowBytes
previousRow = row
}
return { minimum, maximum }
}
function verifyPng(baseDirectory, relativePath, expected, code) {
const { file } = readRequired(baseDirectory, relativePath, code)
assert(sha256(file) === expected.sha256, `${code}_hash`)
const png = parsePng(file, basename(relativePath))
assert(png.width === expected.width, `${code}_width`)
assert(png.height === expected.height, `${code}_height`)
assert(png.bitDepth === expected.bitDepth, `${code}_bit_depth`)
assert(png.colorType === expected.colorType, `${code}_color_type`)
if (expected.alpha === 'opaque') {
if (png.colorType === 6) {
const alpha = rgba8AlphaRange(png, basename(relativePath))
assert(alpha.minimum === 255 && alpha.maximum === 255, `${code}_not_opaque`)
} else {
assert(!png.hasTransparencyChunk, `${code}_transparency_chunk`)
}
}
if (expected.alpha === 'has_transparency') {
const alpha = rgba8AlphaRange(png, basename(relativePath))
assert(alpha.minimum < 255 && alpha.maximum === 255, `${code}_transparency_range`)
}
if (expected.alpha === 'no_alpha_channel') {
assert(png.colorType !== 4 && png.colorType !== 6, `${code}_alpha_channel`)
assert(!png.hasTransparencyChunk, `${code}_transparency_chunk`)
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function verifySvg(baseDirectory, asset, code) {
const { file } = readRequired(baseDirectory, asset.path, code)
assert(sha256(file) === asset.sha256, `${code}_hash`)
const text = file.toString('utf8')
assert(/<svg\b/.test(text), `${code}_not_svg`)
assert(new RegExp(`viewBox=["']${escapeRegExp(asset.viewBox)}["']`).test(text), `${code}_viewbox`)
}
function verifyFeatureGraphics(root) {
const assetDirectory = resolveInside(
root,
featureAssetDirectoryRelative,
'feature_asset_directory'
)
const manifest = readJson(assetDirectory, 'manifest.json', 'feature_manifest')
assert(manifest.schemaVersion === 1, 'manifest_schema')
assert(Array.isArray(manifest.candidates) && manifest.candidates.length === 4, 'candidate_count')
assert(
manifest.selection?.status === 'pending_user_selection_and_console_preview',
'selection_status'
)
const candidateIds = new Set()
for (const candidate of manifest.candidates) {
assert(!candidateIds.has(candidate.id), `duplicate_candidate_${candidate.id}`)
candidateIds.add(candidate.id)
const source = readRequired(assetDirectory, candidate.source, `source_${candidate.id}`)
const output = readRequired(assetDirectory, candidate.output, `output_${candidate.id}`)
assert(
statSync(source.absolutePath).size === candidate.sourceBytes,
`source_size_${candidate.id}`
)
assert(
statSync(output.absolutePath).size === candidate.outputBytes,
`output_size_${candidate.id}`
)
assert(sha256(source.file) === candidate.sourceSha256, `source_hash_${candidate.id}`)
assert(sha256(output.file) === candidate.outputSha256, `output_hash_${candidate.id}`)
const sourceText = source.file.toString('utf8')
assert(/<svg\b/.test(sourceText), `source_not_svg_${candidate.id}`)
assert(/viewBox="0 0 1024 500"/.test(sourceText), `source_viewbox_${candidate.id}`)
const png = readPngHeader(output.file, candidate.output)
assert(png.width === manifest.export.width, `png_width_${candidate.id}`)
assert(png.height === manifest.export.height, `png_height_${candidate.id}`)
assert(png.bitDepth === manifest.export.bitDepth, `png_bit_depth_${candidate.id}`)
assert(png.colorType === manifest.export.pngColorType, `png_color_type_${candidate.id}`)
}
assert(candidateIds.has(manifest.selection.recommended), 'recommended_candidate_missing')
return manifest.candidates.length
}
function verifyAndroidIcons(root, android) {
assert(android.icon === '@mipmap/ic_launcher', 'android_icon_contract')
assert(android.roundIcon === '@mipmap/ic_launcher_round', 'android_round_icon_contract')
const manifest = readRequired(
root,
android.applicationManifest,
'android_manifest'
).file.toString('utf8')
assert(
new RegExp(`android:icon\\s*=\\s*["']${escapeRegExp(android.icon)}["']`).test(manifest),
'android_manifest_icon'
)
assert(
new RegExp(`android:roundIcon\\s*=\\s*["']${escapeRegExp(android.roundIcon)}["']`).test(
manifest
),
'android_manifest_round_icon'
)
const colors = readRequired(
root,
android.background.path,
'android_launcher_colors'
).file.toString('utf8')
const backgroundPattern = new RegExp(
`<color\\s+name=["']${escapeRegExp(android.background.resource)}["']\\s*>\\s*${escapeRegExp(android.background.value)}\\s*</color>`,
'i'
)
assert(backgroundPattern.test(colors), 'android_launcher_background')
assert(
Array.isArray(android.sourceAssets) && android.sourceAssets.length === 2,
'android_source_count'
)
android.sourceAssets.forEach((asset, index) => verifySvg(root, asset, `android_source_${index}`))
for (const [role, expectedPath] of [
['foreground', 'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_foreground.xml'],
['monochrome', 'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_monochrome.xml']
]) {
const vector = android.vectors?.[role]
assert(vector?.path === expectedPath, `android_${role}_vector_path`)
const { file } = readRequired(root, vector.path, `android_${role}_vector`)
assert(sha256(file) === vector.sha256, `android_${role}_vector_hash`)
const text = file.toString('utf8')
assert(/<vector\b/.test(text), `android_${role}_vector_root`)
assert(/android:viewportWidth="108"/.test(text), `android_${role}_vector_width`)
assert(/android:viewportHeight="108"/.test(text), `android_${role}_vector_height`)
}
assert(
Array.isArray(android.adaptiveIcons) && android.adaptiveIcons.length === 4,
'android_adaptive_icon_count'
)
const requiredAdaptivePaths = new Map([
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml', false],
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml', false],
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml', true],
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml', true]
])
const adaptivePaths = new Set()
for (const adaptive of android.adaptiveIcons) {
assert(!adaptivePaths.has(adaptive.path), 'android_adaptive_icon_duplicate')
adaptivePaths.add(adaptive.path)
assert(requiredAdaptivePaths.has(adaptive.path), 'android_adaptive_icon_path')
assert(
adaptive.monochrome === requiredAdaptivePaths.get(adaptive.path),
'android_adaptive_icon_api_contract'
)
const text = readRequired(root, adaptive.path, 'android_adaptive_icon').file.toString('utf8')
assert(/<adaptive-icon\b/.test(text), 'android_adaptive_icon_root')
assert(
/<background\s+android:drawable="@color\/d3ro_launcher_background"\s*\/>/.test(text),
'android_adaptive_icon_background'
)
assert(
/<foreground\s+android:drawable="@drawable\/d3ro_launcher_foreground"\s*\/>/.test(text),
'android_adaptive_icon_foreground'
)
const hasMonochrome =
/<monochrome\s+android:drawable="@drawable\/d3ro_launcher_monochrome"\s*\/>/.test(text)
assert(hasMonochrome === adaptive.monochrome, 'android_adaptive_icon_monochrome')
assert(adaptive.path.includes(adaptive.monochrome ? '-v33/' : '-v26/'), 'android_adaptive_api')
}
for (const requiredPath of requiredAdaptivePaths.keys()) {
assert(
adaptivePaths.has(requiredPath),
`android_adaptive_icon_missing_${basename(requiredPath)}`
)
}
assert(
Array.isArray(android.legacyIcons) &&
android.legacyIcons.length === androidLegacyDensities.size,
'android_legacy_icon_count'
)
const densities = new Set()
for (const legacy of android.legacyIcons) {
const expectedPixels = androidLegacyDensities.get(legacy.density)
assert(expectedPixels !== undefined, `android_density_${legacy.density}`)
assert(!densities.has(legacy.density), `android_density_duplicate_${legacy.density}`)
densities.add(legacy.density)
assert(legacy.pixels === expectedPixels, `android_density_pixels_${legacy.density}`)
const basePath = `apps/mobile-rn/android/app/src/main/res/mipmap-${legacy.density}`
assert(legacy.iconPath === `${basePath}/ic_launcher.png`, `android_icon_path_${legacy.density}`)
assert(
legacy.roundPath === `${basePath}/ic_launcher_round.png`,
`android_round_path_${legacy.density}`
)
const expected = {
width: expectedPixels,
height: expectedPixels,
bitDepth: 8,
colorType: 6,
alpha: 'has_transparency'
}
verifyPng(
root,
legacy.iconPath,
{ ...expected, sha256: legacy.iconSha256 },
`android_icon_${legacy.density}`
)
verifyPng(
root,
legacy.roundPath,
{ ...expected, sha256: legacy.roundSha256 },
`android_round_icon_${legacy.density}`
)
}
}
function slotKey(icon) {
return `${icon.idiom}|${icon.size}|${icon.scale}`
}
function verifyIosIcons(root, ios) {
assert(
ios.source === 'docs/v3/play/assets/d3ro-voice-play-icon-source.svg',
'ios_canonical_source'
)
assert(JSON.stringify(ios.targetedDeviceFamilies) === '[1,2]', 'ios_targeted_families_contract')
assert(
ios.bitDepth === 8 && ios.colorType === 2 && ios.alphaChannel === false,
'ios_png_contract'
)
assert(Array.isArray(ios.icons) && ios.icons.length === requiredIosSlots.length, 'ios_icon_count')
const requiredBySlot = new Map(
requiredIosSlots.map(([idiom, size, scale, filename, pixels]) => [
`${idiom}|${size}|${scale}`,
{ idiom, size, scale, filename, pixels }
])
)
const manifestBySlot = new Map()
for (const icon of ios.icons) {
const key = slotKey(icon)
assert(!manifestBySlot.has(key), `ios_icon_duplicate_${key}`)
manifestBySlot.set(key, icon)
const required = requiredBySlot.get(key)
assert(required, `ios_icon_unexpected_${key}`)
assert(icon.filename === required.filename, `ios_icon_filename_${key}`)
assert(icon.pixels === required.pixels, `ios_icon_pixels_${key}`)
assert(
typeof icon.sha256 === 'string' && icon.sha256.length === 64,
`ios_icon_hash_contract_${key}`
)
}
for (const key of requiredBySlot.keys()) {
assert(manifestBySlot.has(key), `ios_icon_missing_${key}`)
}
const contents = readJson(root, ios.contents, 'ios_appicon_contents')
assert(contents.info?.author === 'xcode' && contents.info?.version === 1, 'ios_contents_info')
assert(
Array.isArray(contents.images) && contents.images.length === requiredIosSlots.length,
'ios_contents_count'
)
const contentsBySlot = new Map()
for (const entry of contents.images) {
const key = slotKey(entry)
assert(!contentsBySlot.has(key), `ios_contents_duplicate_${key}`)
contentsBySlot.set(key, entry)
const icon = manifestBySlot.get(key)
assert(icon, `ios_contents_unexpected_${key}`)
assert(entry.filename === icon.filename, `ios_contents_filename_${key}`)
}
for (const key of manifestBySlot.keys()) {
assert(contentsBySlot.has(key), `ios_contents_missing_${key}`)
}
const contentsDirectory = dirname(resolveInside(root, ios.contents, 'ios_contents_path'))
const expectedPngFiles = new Set(ios.icons.map((icon) => icon.filename))
const actualPngFiles = new Set(
readdirSync(contentsDirectory).filter((name) => name.endsWith('.png'))
)
assert(actualPngFiles.size === expectedPngFiles.size, 'ios_unassigned_png_count')
for (const filename of actualPngFiles) {
assert(expectedPngFiles.has(filename), `ios_unassigned_png_${filename}`)
}
for (const icon of ios.icons) {
assert(basename(icon.filename) === icon.filename, `ios_icon_filename_path_${icon.filename}`)
verifyPng(
contentsDirectory,
icon.filename,
{
width: icon.pixels,
height: icon.pixels,
bitDepth: ios.bitDepth,
colorType: ios.colorType,
alpha: 'no_alpha_channel',
sha256: icon.sha256
},
`ios_icon_${icon.idiom}_${icon.size}_${icon.scale}`
)
}
const project = readRequired(root, ios.project, 'ios_xcode_project').file.toString('utf8')
assert(
(project.match(/ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;/g) ?? []).length >= 2,
'ios_xcode_appicon_binding'
)
assert(
(project.match(/TARGETED_DEVICE_FAMILY = "1,2";/g) ?? []).length >= 2,
'ios_xcode_targeted_families'
)
}
function verifyMobileIcons(root) {
const manifest = readJson(root, mobileIconManifestRelative, 'mobile_icon_manifest')
assert(manifest.schemaVersion === 1, 'mobile_icon_manifest_schema')
assert(
manifest.canonicalSource?.path === 'docs/v3/play/assets/d3ro-voice-play-icon-source.svg',
'canonical_icon_source_path'
)
verifySvg(root, manifest.canonicalSource, 'canonical_icon_source')
assert(
manifest.playStoreIcon?.path === 'docs/v3/play/assets/d3ro-voice-play-icon-512.png',
'play_icon_path'
)
verifyPng(root, manifest.playStoreIcon.path, manifest.playStoreIcon, 'play_icon')
verifyAndroidIcons(root, manifest.android)
verifyIosIcons(root, manifest.ios)
verifyPng(
root,
manifest.pixelLauncherProof.path,
{
...manifest.pixelLauncherProof,
bitDepth: 8,
colorType: 6,
alpha: 'opaque'
},
'pixel_launcher_proof'
)
return {
androidLegacyIconCount: manifest.android.legacyIcons.length * 2,
iosIconCount: manifest.ios.icons.length
}
}
function verifyAll(root, printSummary = true) {
const featureCandidateCount = verifyFeatureGraphics(root)
const mobile = verifyMobileIcons(root)
if (printSummary) {
console.log(
`Play store assets verified: ${featureCandidateCount} feature candidates, canonical 512 icon, ${mobile.androidLegacyIconCount} Android legacy icons + adaptive/monochrome, ${mobile.iosIconCount} iOS/iPad icons, hashes and alpha contracts GREEN`
)
}
}
function copyFixture(relativePath, temporaryRoot) {
const source = resolveInside(workspaceRoot, relativePath, 'self_test_source')
const destination = resolveInside(temporaryRoot, relativePath, 'self_test_destination')
mkdirSync(dirname(destination), { recursive: true })
cpSync(source, destination, { recursive: true })
}
function createSelfTestFixture() {
const temporaryRoot = mkdtempSync(join(tmpdir(), 'd3ro-mobile-icon-assets-'))
for (const relativePath of [
'docs/v3/play/assets',
'apps/mobile-rn/android/app/src/main/AndroidManifest.xml',
'apps/mobile-rn/android/app/src/main/res',
'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset',
'apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj'
]) {
copyFixture(relativePath, temporaryRoot)
}
return temporaryRoot
}
function expectSelfTestFailure(name, mutate, expectedCode) {
const temporaryRoot = createSelfTestFixture()
try {
mutate(temporaryRoot)
let failure
try {
verifyAll(temporaryRoot, false)
} catch (error) {
failure = error
}
assert(failure instanceof Error, `self_test_${name}_unexpected_green`)
assert(
failure.message === `play_store_asset_verification_failed:${expectedCode}`,
`self_test_${name}_wrong_failure_${failure.message}`
)
console.log(`Negative self-test ${name}: ${expectedCode} RED as expected`)
} finally {
rmSync(temporaryRoot, { recursive: true, force: true })
}
}
function runSelfTests() {
verifyAll(workspaceRoot, false)
expectSelfTestFailure(
'android_manifest_icon',
(root) => {
const path = resolveInside(
root,
'apps/mobile-rn/android/app/src/main/AndroidManifest.xml',
'self_test_android_manifest'
)
writeFileSync(
path,
readFileSync(path, 'utf8').replace('@mipmap/ic_launcher"', '@mipmap/missing"')
)
},
'android_manifest_icon'
)
expectSelfTestFailure(
'android_density_dimensions',
(root) => {
const manifestPath = resolveInside(
root,
mobileIconManifestRelative,
'self_test_mobile_manifest'
)
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const hdpi = manifest.android.legacyIcons.find((icon) => icon.density === 'hdpi')
const mdpi = manifest.android.legacyIcons.find((icon) => icon.density === 'mdpi')
const mdpiBytes = readFileSync(resolveInside(root, mdpi.iconPath, 'self_test_mdpi_icon'))
writeFileSync(resolveInside(root, hdpi.iconPath, 'self_test_hdpi_icon'), mdpiBytes)
hdpi.iconSha256 = sha256(mdpiBytes)
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
},
'android_icon_hdpi_width'
)
expectSelfTestFailure(
'android_monochrome_missing',
(root) => {
unlinkSync(
resolveInside(
root,
'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_monochrome.xml',
'self_test_monochrome'
)
)
},
'android_monochrome_vector_missing'
)
expectSelfTestFailure(
'ios_icon_missing',
(root) => {
unlinkSync(
resolveInside(
root,
'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset/AppIcon-marketing-1024@1x.png',
'self_test_ios_marketing_icon'
)
)
},
'ios_unassigned_png_count'
)
expectSelfTestFailure(
'ios_contents_filename',
(root) => {
const path = resolveInside(
root,
'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset/Contents.json',
'self_test_ios_contents'
)
const contents = JSON.parse(readFileSync(path, 'utf8'))
contents.images[0].filename = 'missing.png'
writeFileSync(path, `${JSON.stringify(contents, null, 2)}\n`)
},
'ios_contents_filename_iphone|20x20|2x'
)
expectSelfTestFailure(
'ios_alpha_channel',
(root) => {
const manifestPath = resolveInside(
root,
mobileIconManifestRelative,
'self_test_mobile_manifest'
)
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const icon = manifest.ios.icons.find(
(candidate) =>
candidate.idiom === 'iphone' && candidate.size === '20x20' && candidate.scale === '2x'
)
const contentsDirectory = dirname(
resolveInside(root, manifest.ios.contents, 'self_test_ios_contents')
)
const path = resolveInside(contentsDirectory, icon.filename, 'self_test_ios_icon')
const bytes = readFileSync(path)
bytes[25] = 6
writeFileSync(path, bytes)
icon.sha256 = sha256(bytes)
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
},
'ios_icon_iphone_20x20_2x_color_type'
)
console.log('Play/mobile icon negative self-tests GREEN: 6 fail-closed cases')
}
const argumentsSet = new Set(process.argv.slice(2))
for (const argument of argumentsSet) {
assert(argument === '--self-test', `unknown_argument_${argument}`)
}
if (argumentsSet.has('--self-test')) {
runSelfTests()
} else {
verifyAll(workspaceRoot)
}

View file

@ -0,0 +1,50 @@
import {
createHash,
createPrivateKey,
createPublicKey,
sign,
timingSafeEqual,
verify,
} from 'node:crypto'
import { readFileSync } from 'node:fs'
const args = process.argv.slice(2)
const privateKey = createPrivateKey(readFileSync(option('--private-key')))
const publicKey = createPublicKey(readFileSync(option('--public-key')))
if (privateKey.asymmetricKeyType !== 'ed25519' || publicKey.asymmetricKeyType !== 'ed25519') {
fail('Both release-evidence keys must be Ed25519.')
}
const derivedPublic = createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
const suppliedPublic = publicKey.export({ type: 'spki', format: 'der' })
if (derivedPublic.length !== suppliedPublic.length || !timingSafeEqual(derivedPublic, suppliedPublic)) {
fail('Release-evidence private and public keys do not match.')
}
const message = Buffer.from('d3ro-release-evidence-key-pair-check-v1')
const signature = sign(null, message, privateKey)
if (!verify(null, message, publicKey, signature)) fail('Release-evidence signature round trip failed.')
const keyId = createHash('sha256').update(suppliedPublic).digest('hex')
const expectedKeyId = option('--expected-key-id')
if (keyId !== expectedKeyId) fail(`Release-evidence key ID mismatch: ${keyId}`)
process.stdout.write(`${JSON.stringify({
ok: true,
algorithm: 'Ed25519',
keyId,
signatureRoundTrip: true,
}, null, 2)}\n`)
function option(name) {
const index = args.indexOf(name)
const value = index === -1 ? undefined : args[index + 1]
if (!value || value.startsWith('--')) fail(`${name} is required.`)
return value
}
function fail(message) {
process.stderr.write(`[release-evidence-key] ${message}\n`)
process.exit(1)
}

View file

@ -0,0 +1,245 @@
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`)

View file

@ -0,0 +1,102 @@
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$credentialTarget = 'D3ROVoice-Android-Upload-20260821'
$keystorePath = 'C:\Users\encep\.d3ro\release\d3ro-upload-key-20260821.p12'
$expectedAlias = 'd3ro-upload-20260821'
$releaseIdentity = Get-Content -Raw -LiteralPath 'release\android-release-identity.json' | ConvertFrom-Json
$expectedSha256 = $releaseIdentity.uploadCertificateSha256
if (-not (Test-Path -LiteralPath $keystorePath -PathType Leaf)) {
throw "Android upload keystore is missing: $keystorePath"
}
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class D3ROCredentialReader
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct Credential
{
public UInt32 Flags;
public UInt32 Type;
public string TargetName;
public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public UInt32 CredentialBlobSize;
public IntPtr CredentialBlob;
public UInt32 Persist;
public UInt32 AttributeCount;
public IntPtr Attributes;
public string TargetAlias;
public string UserName;
}
[DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredRead(string target, UInt32 type, UInt32 flags, out IntPtr credential);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern void CredFree(IntPtr credential);
}
'@
$credentialPointer = [IntPtr]::Zero
$password = $null
try {
if (-not [D3ROCredentialReader]::CredRead($credentialTarget, 1, 0, [ref]$credentialPointer)) {
$errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "Windows credential is unavailable: $credentialTarget (Win32 $errorCode)"
}
$credential = [Runtime.InteropServices.Marshal]::PtrToStructure(
$credentialPointer,
[type][D3ROCredentialReader+Credential]
)
if ($credential.CredentialBlobSize -eq 0) {
throw "Windows credential has an empty secret: $credentialTarget"
}
$blob = [byte[]]::new($credential.CredentialBlobSize)
[Runtime.InteropServices.Marshal]::Copy($credential.CredentialBlob, $blob, 0, $blob.Length)
$password = [Text.Encoding]::Unicode.GetString($blob).TrimEnd([char]0)
if ($password.Length -lt 20) {
throw 'Stored Android upload key password fails the minimum length policy.'
}
$env:D3RO_UPLOAD_KEY_PASSWORD_CHECK = $password
$keytoolOutput = & keytool.exe -list -v `
-storetype PKCS12 `
-keystore $keystorePath `
-storepass:env D3RO_UPLOAD_KEY_PASSWORD_CHECK 2>&1
if ($LASTEXITCODE -ne 0) {
throw "keytool could not read the Android upload keystore: $keytoolOutput"
}
$rendered = $keytoolOutput -join "`n"
if ($rendered -notmatch [regex]::Escape($expectedAlias)) {
throw "Android upload key alias mismatch. Expected $expectedAlias."
}
if ($rendered -notmatch 'PrivateKeyEntry') {
throw 'Android upload keystore does not contain a private key entry.'
}
if ($rendered -notmatch [regex]::Escape($expectedSha256)) {
throw "Android upload certificate mismatch. Expected $expectedSha256."
}
[pscustomobject]@{
ok = $true
alias = $expectedAlias
certificateSha256 = $expectedSha256
keystoreBytes = (Get-Item -LiteralPath $keystorePath).Length
privateKeyReadable = $true
credentialTargetPresent = $true
} | ConvertTo-Json
}
finally {
Remove-Item Env:D3RO_UPLOAD_KEY_PASSWORD_CHECK -ErrorAction SilentlyContinue
$password = $null
if ($credentialPointer -ne [IntPtr]::Zero) {
[D3ROCredentialReader]::CredFree($credentialPointer)
}
}

View file

@ -91,18 +91,57 @@ Write-Host "`n[3/4] Packaging NAS deployment files..." -ForegroundColor Yellow
# Copy docker-compose.nas.yml as docker-compose.yml in package
Copy-Item (Join-Path $PSScriptRoot "..\docker-compose.nas.yml") (Join-Path $nasPkgDir "docker-compose.yml") -Force
# Copy or generate .env in package
if (Test-Path $rootEnvFile) {
Copy-Item $rootEnvFile (Join-Path $nasPkgDir ".env") -Force
} else {
$envContent = @"
# Generate an allowlisted deployment environment. Never copy the repository
# .env because it can also contain Git, SSH, and NAS credentials.
$requiredDeploymentSecrets = @(
"JWT_SECRET",
"JWT_ISSUER",
"JWT_AUDIENCE",
"ADMIN_BOOTSTRAP_TOKEN",
"D3RO_API_TOKEN",
"ADMIN_SESSION_SECRET",
"API_SERVER_URL",
"CORS_ALLOWED_ORIGINS",
"ALLOWED_HOSTS"
)
$deploymentSecrets = @{}
foreach ($name in $requiredDeploymentSecrets) {
$value = if ($envDict.ContainsKey($name)) { $envDict[$name] } else { [Environment]::GetEnvironmentVariable($name) }
if ([string]::IsNullOrWhiteSpace($value)) {
throw "$name is required for a NAS deployment package."
}
if ($value.Contains("`r") -or $value.Contains("`n")) {
throw "$name must be a single-line value."
}
$deploymentSecrets[$name] = $value
}
if ([Text.Encoding]::UTF8.GetByteCount($deploymentSecrets["D3RO_API_TOKEN"]) -lt 32) {
throw "D3RO_API_TOKEN must contain at least 32 UTF-8 bytes."
}
$envContent = @"
PORT=$Port
DATA_PATH=./data
JWT_SECRET=D3ROVoice_Super_Secure_Secret_Key_2026_Key!
JWT_SECRET=$($deploymentSecrets["JWT_SECRET"])
JWT_ISSUER=$($deploymentSecrets["JWT_ISSUER"])
JWT_AUDIENCE=$($deploymentSecrets["JWT_AUDIENCE"])
ADMIN_BOOTSTRAP_TOKEN=$($deploymentSecrets["ADMIN_BOOTSTRAP_TOKEN"])
D3RO_API_TOKEN=$($deploymentSecrets["D3RO_API_TOKEN"])
ADMIN_SESSION_SECRET=$($deploymentSecrets["ADMIN_SESSION_SECRET"])
API_SERVER_URL=$($deploymentSecrets["API_SERVER_URL"])
CORS_ALLOWED_ORIGINS=$($deploymentSecrets["CORS_ALLOWED_ORIGINS"])
ALLOWED_HOSTS=$($deploymentSecrets["ALLOWED_HOSTS"])
TZ=Asia/Seoul
"@
Set-Content -Path (Join-Path $nasPkgDir ".env") -Value $envContent -Encoding UTF8
}
$deploymentEnvPath = Join-Path $nasPkgDir ".env"
Set-Content -Path $deploymentEnvPath -Value $envContent -Encoding UTF8
$deploymentEnvAcl = Get-Acl -LiteralPath $deploymentEnvPath
$deploymentEnvAcl.SetAccessRuleProtection($true, $false)
$deploymentEnvAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
[System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
[System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Allow
))
Set-Acl -LiteralPath $deploymentEnvPath -AclObject $deploymentEnvAcl
# Copy control script
Copy-Item (Join-Path $PSScriptRoot "nas-control.sh") (Join-Path $nasPkgDir "nas-control.sh") -Force -ErrorAction SilentlyContinue

View file

@ -6,6 +6,36 @@
set -e
: "${JWT_SECRET:?JWT_SECRET is required}"
: "${JWT_ISSUER:?JWT_ISSUER is required}"
: "${JWT_AUDIENCE:?JWT_AUDIENCE is required}"
: "${ADMIN_BOOTSTRAP_TOKEN:?ADMIN_BOOTSTRAP_TOKEN is required}"
: "${D3RO_API_TOKEN:?D3RO_API_TOKEN is required}"
: "${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required}"
: "${API_SERVER_URL:?API_SERVER_URL is required}"
: "${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required}"
: "${ALLOWED_HOSTS:?ALLOWED_HOSTS is required}"
for value in \
"$JWT_SECRET" \
"$JWT_ISSUER" \
"$JWT_AUDIENCE" \
"$ADMIN_BOOTSTRAP_TOKEN" \
"$D3RO_API_TOKEN" \
"$ADMIN_SESSION_SECRET" \
"$API_SERVER_URL" \
"$CORS_ALLOWED_ORIGINS" \
"$ALLOWED_HOSTS"; do
case "$value" in
*$'\n'*|*$'\r'*) echo "Deployment values must be single-line" >&2; exit 1 ;;
esac
done
if [ "${#D3RO_API_TOKEN}" -lt 32 ]; then
echo "D3RO_API_TOKEN must contain at least 32 characters" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@ -44,9 +74,18 @@ chmod +x "$OUT_DIR/nas-control.sh"
cat <<EOF > "$OUT_DIR/.env"
PORT=$PORT
DATA_PATH=./data
JWT_SECRET=D3ROVoice_Super_Secure_Secret_Key_2026_Key!
JWT_SECRET=$JWT_SECRET
JWT_ISSUER=$JWT_ISSUER
JWT_AUDIENCE=$JWT_AUDIENCE
ADMIN_BOOTSTRAP_TOKEN=$ADMIN_BOOTSTRAP_TOKEN
D3RO_API_TOKEN=$D3RO_API_TOKEN
ADMIN_SESSION_SECRET=$ADMIN_SESSION_SECRET
API_SERVER_URL=$API_SERVER_URL
CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS
ALLOWED_HOSTS=$ALLOWED_HOSTS
TZ=Asia/Seoul
EOF
chmod 600 "$OUT_DIR/.env"
cat <<EOF > "$OUT_DIR/README.txt"
========================================================================

View file

@ -0,0 +1,88 @@
// scripts/deploy-site-to-nas.js
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const MOBILE_RELEASE_PATTERN = /(?:\.apk|\.aab|android[^/\\]*\.zip|signed[^/\\]*\.zip)$/i;
const MOBILE_DOWNLOAD_REFERENCE = /(?:d3ro-voice[^"']*\.apk|git\.chanpaca\.net\/attachments\/(?:0b015367-dd8b-488c-8cc0-4db413b51792|d2e1b123-5678-496a-bf74-bc188938c999))/i;
function assertNoMobileArtifacts(root) {
if (!fs.existsSync(root)) return;
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isSymbolicLink()) throw new Error(`Refusing symlink in deploy tree: ${candidate}`);
if (entry.isDirectory()) pending.push(candidate);
if (entry.isFile() && MOBILE_RELEASE_PATTERN.test(entry.name)) {
throw new Error(`Unsealed mobile artifact blocked from NAS deploy: ${candidate}`);
}
if (entry.isFile() && /\.(?:html|js|json)$/i.test(entry.name)) {
const source = fs.readFileSync(candidate, 'utf8');
if (MOBILE_DOWNLOAD_REFERENCE.test(source)) {
throw new Error(`Legacy mobile download link blocked from NAS deploy: ${candidate}`);
}
}
}
}
}
async function deploy() {
assertNoMobileArtifacts(path.resolve(__dirname, '../site/dist'));
console.log('=== 1. Building Site ===');
const build = spawnSync('npm', ['run', 'build', '--prefix', 'site'], { stdio: 'inherit', shell: true });
if (build.status !== 0) throw new Error('Site build failed');
assertNoMobileArtifacts(path.resolve(__dirname, '../site/dist'));
console.log('=== 2. Creating tar archive of site/dist ===');
const tarPath = path.resolve(__dirname, '../out/site-dist.tar');
if (!fs.existsSync(path.dirname(tarPath))) fs.mkdirSync(path.dirname(tarPath), { recursive: true });
// Use tar to create archive
const tarRes = spawnSync('tar', ['-cf', tarPath, '-C', 'site/dist', '.'], { stdio: 'inherit', shell: true });
if (tarRes.status !== 0) throw new Error('Tar creation failed');
console.log(`Created archive: ${tarPath} (${(fs.statSync(tarPath).size / 1024 / 1024).toFixed(2)} MB)`);
console.log('=== 3. Uploading archive to Synology NAS via SCP ===');
const scpRes = spawnSync('scp', [
'-o', 'StrictHostKeyChecking=no',
tarPath,
'yunchan@192.168.0.39:/volume1/docker/d3ro/site-dist.tar'
], { stdio: 'inherit', shell: true });
if (scpRes.status !== 0) throw new Error('SCP upload failed');
console.log('=== 4. Extracting on NAS and applying to d3ro_voice_api container ===');
const sshCmd = `
mkdir -p /volume1/docker/d3ro/wwwroot &&
tar -xf /volume1/docker/d3ro/site-dist.tar -C /volume1/docker/d3ro/wwwroot &&
docker cp /volume1/docker/d3ro/wwwroot/. d3ro_voice_api:/app/wwwroot/ &&
docker exec d3ro_voice_api ls -la /app/wwwroot &&
docker exec d3ro_voice_api find /app/wwwroot -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q . && exit 1 || true
`;
const sshRes = spawnSync('ssh', [
'-o', 'StrictHostKeyChecking=no',
'yunchan@192.168.0.39',
sshCmd
], { stdio: 'inherit', shell: true });
if (sshRes.status !== 0) throw new Error('SSH extract failed');
console.log('=== 5. Updating docker-compose.yml on NAS for persistent mount ===');
const updateComposeCmd = `
sed -i 's|- /volume1/docker/d3ro/data:/app/data|- /volume1/docker/d3ro/data:/app/data\\n - /volume1/docker/d3ro/wwwroot:/app/wwwroot|g' /volume1/docker/d3ro/docker-compose.yml || true
`;
spawnSync('ssh', ['-o', 'StrictHostKeyChecking=no', 'yunchan@192.168.0.39', updateComposeCmd], { stdio: 'inherit', shell: true });
console.log('✓ Successfully deployed site and releases to NAS!');
}
module.exports = { assertNoMobileArtifacts };
if (require.main === module) {
deploy().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}

View file

@ -0,0 +1,74 @@
const { _electron: electron } = require('playwright');
const path = require('path');
const fs = require('fs');
async function runFullJourney() {
const outDir = 'C:/Users/encep/.gemini/antigravity-cli/brain/3ab7ae82-5634-41d5-93a7-3c12c22503e2/screenshots/live_production_verified';
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const app = await electron.launch({
executablePath: require('electron'),
args: ['.'],
cwd: path.resolve('apps/desktop'),
env: Object.assign({}, process.env, { NODE_ENV: 'production' })
});
console.log('Electron Launched, PID:', app.process().pid);
const window = await app.firstWindow({timeout: 30000});
await window.waitForLoadState('domcontentloaded');
await window.waitForTimeout(2000);
// 1. Dashboard Screenshot
await window.screenshot({ path: path.join(outDir, '08_electron_dashboard.png') });
console.log('1. Saved Dashboard');
// 2. Click History
const hightoryEl = await window.$('div >> text="his"i, div >> text="히스토리"');
if (hightoryEl) {
await hightoryEl.click();
await window.waitForTimeout(1500);
await window.screenshot({ path: path.join(outDir, '09_electron_history.png') });
console.log('2. Saved History');
}
// 3. Click Commands
console.log('Clicking Commands...');
console.log('Clicking Commands tab.');
await window.evaluate(() => {
const els = Array.from(document.querySelectorAll('div, button, a'));
const cmdEl = els.find(e => e.innerText && e.innerText.trim() === '몵령어');
if (cmdEl) cmdEl.click();
});
await window.waitForTimeout(1500);
await window.screenshot({ path: path.join(outDir, '10_electron_commands.png') });
console.log('3. Saved Commands');
// 4. Click Meeting Mode
await window.evaluate(() => {
const els = Array.from(document.querySelectorAll('div, button, a'));
const meetEl = els.find(e => e.innerText && e.innerText.trim() === 'meeting' || e.innerText.trim() === '뚜환');
if (meetEl) meetEl.click();
});
await window.waitForTimeout(1500);
await window.screenshot({ path: path.join(outDir, '11_electron_meeting_mode.png') });
console.log('4. Saved Meeting Mode');
// 5. Click Settings
await window.evaluate(() => {
const els = Array.from(document.querySelectorAll('div, button, a'));
const settEl = els.find(e => e.innerText && (e.innerText.trim() === 'settings' || e.innerText.trim() === '설앩'));
if (settEl) settEl.click();
});
await window.waitForTimeout(1500);
await window.screenshot({ path: path.join(outDir, '12_electron_settings_modal.png') });
console.log('5. Saved Settings Modal');
await app.close();
console.log('FULL_JOUT<EFBFBD><EFBFBD>VW<EFBFBD><EFBFBD>P<EFBFBD><EFBFBD>T<EFBFBD><EFBFBD><EFBFBD>NŸB<EFBFBD><EFBFBD><EFBFBD>[<EFBFBD><EFBFBD>[<EFBFBD><EFBFBD>\<EFBFBD><EFBFBD>^J
K<EFBFBD>[<EFBFBD>
HO<EFBFBD><EFBFBD><EFBFBD><EFBFBD>\<EFBFBD>˙^]
JK<EFBFBD><EFBFBD>]<EFBFBD>
\<EFBFBD><EFBFBD>O<EFBFBD>ˆ<EFBFBD>ۜ<EFBFBD><EFBFBD>K<EFBFBD>\<EFBFBD><EFBFBD>܊ ғ<EFBFBD>T<EFBFBD><EFBFBD>VHT<EFBFBD><EFBFBD>Ԏ<EFBFBD><EFBFBD>\<EFBFBD><EFBFBD>Nˆ<EFBFBD><EFBFBD><EFBFBD>\<EFBFBD>˙^]
JNŸJN

54
scripts/gen-keystore.js Normal file
View file

@ -0,0 +1,54 @@
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const keytool = 'C:\\\\Program Files\\\\Eclipse Adoptium\\\\jdk-17.0.14.7-hotspot\\\\bin\\\\keytool.exe';
const allowedDirectory = fs.realpathSync(path.resolve('apps/mobile-rn/android/app'));
const required = [
'D3RO_RELEASE_STORE_FILE',
'D3RO_RELEASE_STORE_PASSWORD',
'D3RO_RELEASE_KEY_ALIAS',
'D3RO_RELEASE_KEY_PASSWORD',
];
const missing = required.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(`Missing required release signing settings: ${missing.join(', ')}`);
}
const keystorePath = path.resolve(process.env.D3RO_RELEASE_STORE_FILE);
if (path.dirname(keystorePath) !== allowedDirectory || !/\.(?:keystore|p12)$/i.test(keystorePath)) {
throw new Error('D3RO_RELEASE_STORE_FILE must be a keystore inside android/app');
}
if (fs.existsSync(keystorePath)) {
throw new Error('Refusing to overwrite an existing release keystore');
}
if (process.env.D3RO_RELEASE_STORE_PASSWORD.length < 20) {
throw new Error('D3RO_RELEASE_STORE_PASSWORD must contain at least 20 characters');
}
if (process.env.D3RO_RELEASE_STORE_PASSWORD !== process.env.D3RO_RELEASE_KEY_PASSWORD) {
throw new Error('PKCS12 store and key passwords must match');
}
if (!/^[A-Za-z0-9._-]{3,64}$/.test(process.env.D3RO_RELEASE_KEY_ALIAS)) {
throw new Error('D3RO_RELEASE_KEY_ALIAS has an invalid format');
}
const args = [
'-genkeypair',
'-v',
'-keystore', keystorePath,
'-storetype', 'PKCS12',
'-alias', process.env.D3RO_RELEASE_KEY_ALIAS,
'-keyalg', 'RSA',
'-keysize', '3072',
'-validity', '10000',
'-storepass:env', 'D3RO_RELEASE_STORE_PASSWORD',
'-keypass:env', 'D3RO_RELEASE_KEY_PASSWORD',
'-dname', process.env.D3RO_RELEASE_DNAME ||
'CN=D3RO Voice AI, OU=Technology, O=Chanpaca Inc, L=Seoul, ST=Seoul, C=KR',
];
const result = spawnSync(keytool, args, { stdio: 'inherit', env: process.env });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Keytool exited with status ${result.status}`);
fs.chmodSync(keystorePath, 0o600);
console.log(`Created release keystore at ${keystorePath}`);

View file

@ -6,6 +6,10 @@ const path = require('path');
const fs = require('fs');
const http = require('http');
const crypto = require('crypto');
const {
forgejoAuthorization,
forgejoLogin,
} = require('./lib/credentials.cjs');
const OUTPUT_DIR = 'C:/Users/encep/.gemini/antigravity-cli/brain/3ab7ae82-5634-41d5-93a7-3c12c22503e2/screenshots/e2e_verification';
const EXPECTED_HASH = 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2';
@ -47,6 +51,7 @@ function serveStatic(dir) {
}
async function runE2E() {
const { username, password } = forgejoLogin();
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const testResults = {
@ -167,9 +172,9 @@ async function runE2E() {
// Test 5: Verify Remote Forgejo Git Server (https://git.chanpaca.net/yunchan/d3ro-voice/releases)
console.log('\n[TEST 6] Verifying Live Remote Forgejo Git Server (git.chanpaca.net)...');
try {
const auth = Buffer.from('yunchan:ONVI2v4J#y').toString('base64');
const authorization = forgejoAuthorization();
const apiRes = await fetch('https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases/tags/v1.0.0', {
headers: { 'Authorization': 'Basic ' + auth }
headers: { 'Authorization': authorization }
});
console.log(' -> Forgejo Release API Status:', apiRes.status);
if (apiRes.status === 200) {
@ -191,8 +196,8 @@ async function runE2E() {
console.log('\n[TEST 7] Capturing Live Forgejo Release UI...');
try {
await page.goto('https://git.chanpaca.net/user/login', { waitUntil: 'networkidle', timeout: 20000 });
await page.fill('input[name="user_name"]', 'yunchan');
await page.fill('input[name="password"]', 'ONVI2v4J#y');
await page.fill('input[name="user_name"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);