diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..09f9ebe --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,188 @@ +name: ci + +# 브랜치·PR 검증. 예전 .github/workflows/ci.yml(GitHub 원격이 없어 한 번도 실행되지 않음)에서 +# 자체 호스팅 linux-builder 러너로 돌릴 수 있는 잡만 옮겼다. +# +# 옮기지 않은 잡: +# - test-matrix의 windows/macos 칸: macOS 러너가 없고, windows 러너는 태그 릴리스 전용으로 둔다. +# - mobile-android APK 빌드 / mobile-emulator-e2e: Android SDK + KVM 에뮬레이터가 필요하다. +# GitLab 미러(.gitlab-ci.yml mobile-android, mobile-emulator-e2e)가 계속 담당한다. +# +# 러너 관례: actions/checkout·setup-* 없이 github.token으로 직접 fetch 한다(다른 .forgejo 워크플로와 동일). +# Deno/.NET은 scripts/ci/bootstrap-linux-toolchain.sh 가 체크섬 검증 후 설치한다. +# git clean -fdx 로 지워지지 않도록 도구 캐시는 체크아웃 밖(CI_PROJECT_DIR)에 둔다. +# +# 필요한 시크릿: 없음 (github.token 만 사용) + +on: + push: + branches: + - main + - develop + - "feature/**" + - "fix/**" + pull_request: + branches: + - main + - develop + workflow_dispatch: + +jobs: + quality: + name: 정본·보안·린트·타입·테스트 + runs-on: linux-builder + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}" + url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git" + [ -d .git ] || git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "$url" + git fetch -q --depth 1 origin "$GITHUB_REF" + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: 도구 버전 + run: | + echo "node $(node --version) (.nvmrc $(cat .nvmrc)) / npm $(npm --version)" + + - name: 의존성 설치 + run: npm ci + + - name: 버전·계약 정본 대조 + run: | + npm run version:check + npm run contract:check + npm run release:metadata:test + + - name: 하드코딩 자격증명 검사 + run: | + npm run security:secrets:test + npm run security:secrets + + - name: 모바일 릴리스 경계·설정 계약 + run: | + npm run release:mobile:boundary:test + npm run release:mobile:config:test + npm run release:mobile:build-config:test + npm run release:play:assets + + - name: 린트 + run: npm run lint + + - name: 타입 검사 + run: npm run typecheck + + - name: 테스트 (Vitest) + run: npm test + + build-validation: + name: 워크스페이스 빌드 검증 + needs: quality + runs-on: linux-builder + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}" + url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git" + [ -d .git ] || git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "$url" + git fetch -q --depth 1 origin "$GITHUB_REF" + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: 의존성 설치 + run: npm ci + + - name: 데스크톱 번들 빌드 + 렌더러 번들 검증 + run: | + npm run build --workspace=@d3ro/desktop + node scripts/ci/verify-desktop-renderer-bundles.mjs + + - name: 어드민 빌드 + run: npm run build --workspace=@d3ro/admin + + mobile-quality: + name: 모바일 린트·타입·Jest + runs-on: linux-builder + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}" + url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git" + [ -d .git ] || git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "$url" + git fetch -q --depth 1 origin "$GITHUB_REF" + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: 의존성 설치 + run: | + npm ci + npm --prefix apps/mobile-rn ci --workspaces=false + + - name: 모바일 검사 + run: | + npm --prefix apps/mobile-rn run lint + npm --prefix apps/mobile-rn run typecheck + npm --prefix apps/mobile-rn run test:ci + + edge-functions: + name: Supabase Edge Functions + Cloudflare Worker + runs-on: linux-builder + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}" + url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git" + [ -d .git ] || git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "$url" + git fetch -q --depth 1 origin "$GITHUB_REF" + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: Deno 2.8.1 설치 후 검사·테스트 + shell: bash + run: | + set -euo pipefail + export CI_PROJECT_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/d3ro-ci" + . scripts/ci/bootstrap-linux-toolchain.sh deno + for entrypoint in server/supabase/functions/*/index.ts; do + deno check --config server/supabase/functions/deno.json "$entrypoint" + done + deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions + deno check --no-config server/cloudflare-worker/src/push-drain.ts + deno test --no-config --allow-read server/cloudflare-worker/src/push-drain.test.ts + + api-server-tests: + name: .NET API 서버 테스트 + runs-on: linux-builder + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}" + url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git" + [ -d .git ] || git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "$url" + git fetch -q --depth 1 origin "$GITHUB_REF" + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: .NET 10.0.302 설치 후 테스트 + shell: bash + run: | + set -euo pipefail + export CI_PROJECT_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/d3ro-ci" + . scripts/ci/bootstrap-linux-toolchain.sh dotnet + dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj + dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false diff --git a/.forgejo/workflows/deploy-site-windows.yml b/.forgejo/workflows/deploy-site-windows.yml deleted file mode 100644 index 4d56a91..0000000 --- a/.forgejo/workflows/deploy-site-windows.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: deploy-site-windows - -on: - workflow_dispatch: - -jobs: - deploy-win: - runs-on: windows - defaults: { run: { shell: pwsh } } - steps: - - name: checkout - env: { CI_TOKEN: "${{ github.token }}" } - run: | - $u = [Uri]$env:GITHUB_SERVER_URL - $url = "$($u.Scheme)://actions:$($env:CI_TOKEN)@$($u.Authority)/$($env:GITHUB_REPOSITORY).git" - if (-not (Test-Path .git)) { git init -q . } - if (git remote | Select-String -Quiet '^origin$') { git remote set-url origin $url } else { git remote add origin $url } - git fetch -q --depth 1 origin $env:GITHUB_REF - git checkout -q -f FETCH_HEAD - git clean -qfdx - - - name: 사이트 빌드 - run: | - npm ci --prefix site - npm run build --prefix site - - - name: Cloudflare Pages 배포 - env: - CLOUDFLARE_API_TOKEN: "${{ secrets.CF_API_TOKEN || secrets.CLOUDFLARE_API_TOKEN }}" - CLOUDFLARE_ACCOUNT_ID: "${{ secrets.CF_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}" - run: | - if ($env:CLOUDFLARE_API_TOKEN) { - npx --yes wrangler@latest pages deploy site/dist --project-name d3ro --branch main --commit-dirty=true - } diff --git a/.forgejo/workflows/deploy-site.yml b/.forgejo/workflows/deploy-site.yml index 13bdbdd..0fe30ab 100644 --- a/.forgejo/workflows/deploy-site.yml +++ b/.forgejo/workflows/deploy-site.yml @@ -1,5 +1,14 @@ name: deploy-site +# 사이트(site/) 배포의 유일한 경로: Cloudflare Pages `d3ro` (d3ro.chanpaca.net). +# 예전 deploy-site-windows.yml(수동 전용·배포 검증 없음)과 .github/workflows/deploy-site.yml +# (GitHub Pages, 실행된 적 없음)을 이 파일로 합쳤다. +# +# site/는 저장소 루트의 packages/core/src/*.ts 를 상대 import 하므로 전체 트리를 체크아웃한다 +# (sparse checkout 금지). +# +# 필요한 시크릿: CF_API_TOKEN(또는 CLOUDFLARE_API_TOKEN), CF_ACCOUNT_ID(또는 CLOUDFLARE_ACCOUNT_ID) + on: push: branches: [main] @@ -21,6 +30,9 @@ jobs: git checkout -q -f FETCH_HEAD git clean -qfdx + - name: 모바일 릴리스 공개 경계 검사 + run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test + - name: 의존성 설치 및 사이트 빌드 run: | npm ci --prefix site diff --git a/.forgejo/workflows/payple-renew.yml b/.forgejo/workflows/payple-renew.yml new file mode 100644 index 0000000..8c35a55 --- /dev/null +++ b/.forgejo/workflows/payple-renew.yml @@ -0,0 +1,47 @@ +name: payple-renew + +# Payple 정기결제 자동 갱신 트리거. 매일 한 번 Supabase Edge Function `payple-renew`를 호출한다. +# 예전 .github/workflows/payple-renew.yml은 GitHub 원격이 없어 한 번도 실행되지 않았다. +# +# 주의: +# - Forgejo schedule은 기본 브랜치(main)에 이 파일이 있을 때만 등록된다. +# - cron 시각은 Forgejo 서버 기준으로 해석된다. 서버가 UTC면 01:00 UTC = 10:00 KST. +# +# 필요한 시크릿: +# SUPABASE_URL — https://.supabase.co (끝 슬래시 없이) +# CRON_SECRET — Supabase Function 시크릿 CRON_SECRET 과 같은 값 + +on: + schedule: + - cron: "0 1 * * *" + workflow_dispatch: + +jobs: + renew: + runs-on: linux-builder + steps: + - name: payple-renew Edge Function 호출 + env: + SUPABASE_URL: "${{ secrets.SUPABASE_URL }}" + CRON_SECRET: "${{ secrets.CRON_SECRET }}" + run: | + set -eu + if [ -z "${SUPABASE_URL:-}" ] || [ -z "${CRON_SECRET:-}" ]; then + echo "SUPABASE_URL / CRON_SECRET 시크릿이 없습니다." >&2 + exit 1 + fi + response=$(curl --silent --show-error --max-time 300 -w "\n%{http_code}" -X POST \ + "${SUPABASE_URL%/}/functions/v1/payple-renew" \ + -H "Authorization: Bearer ${CRON_SECRET}" \ + -H "Content-Type: application/json") + + http_code=$(printf '%s\n' "$response" | tail -n 1) + body=$(printf '%s\n' "$response" | sed '$d') + + echo "HTTP $http_code" + printf '%s\n' "$body" | jq . 2>/dev/null || printf '%s\n' "$body" + + if [ "$http_code" -ge 400 ]; then + echo "갱신 실패: HTTP $http_code" >&2 + exit 1 + fi diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index d42bffd..0154a55 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,9 +1,8 @@ name: release # Canonical tag-triggered desktop release built and published on Forgejo. -# GitLab CI (.gitlab-ci.yml) and GitHub Actions (.github/workflows/release.yml) -# remain alternate builders; all three converge on publish-forgejo-release.mjs -# so the Forgejo feed is the single update source. +# GitLab CI (.gitlab-ci.yml) is the legacy mirror builder; both converge on +# publish-forgejo-release.mjs so the Forgejo feed is the single update source. # # Required repository secrets: # FORGEJO_TOKEN — PAT with write:package + write:repository diff --git a/.github/workflows/build-mac.yml b/.github/workflows/build-mac.yml deleted file mode 100644 index b81eb36..0000000 --- a/.github/workflows/build-mac.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Build macOS - -on: - workflow_dispatch: - inputs: - notarize: - description: 'Apple Notarization 활성화' - required: false - default: 'false' - type: choice - options: - - 'false' - - 'true' - -jobs: - build: - name: Build & Package (macOS) - runs-on: macos-14 # arm64 (Apple Silicon) - timeout-minutes: 60 - - strategy: - fail-fast: false - matrix: - arch: [arm64, x64] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Setup Python (sidecar 빌드용) - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install system deps (sox) - run: brew install sox - - - name: Install npm dependencies - run: npm install - - - name: Bundle SoX into resources - working-directory: apps/desktop - run: bash scripts/install-sox.sh - - - name: Build STT sidecar (PyInstaller) - working-directory: apps/desktop - run: | - # 로컬 전사는 사이드카 번들에 의존한다. 누락된 채 패키징하면 설치본에서 - # 전사가 전혀 동작하지 않으므로 빌드 후 반드시 검증한다. - npm run sidecar:setup - npm run sidecar:build - node ../../scripts/ci/verify-sidecar-bundle.mjs - - - name: Rebuild native modules for Electron - run: npx --yes @electron/rebuild@3 --version=33.4.11 - - - name: Build renderer/preload/main - run: npm run build - - - name: electron-builder dist (mac, ${{ matrix.arch }}) - working-directory: apps/desktop - env: - # Apple 서명/공증 (notarize=true일 때만 사용) - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }} - NOTARIZE: ${{ inputs.notarize || 'false' }} - run: | - if [ "$NOTARIZE" = "true" ] && [ -n "$APPLE_ID" ]; then - npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=true - else - npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=false - fi - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: d3ro-voice-mac-${{ matrix.arch }} - path: | - apps/desktop/release/*.dmg - apps/desktop/release/*.zip - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 0db2b41..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,333 +0,0 @@ -# .github/workflows/ci.yml -# Continuous Integration Pipeline for D3RO Voice Monorepo - -name: CI Pipeline - -on: - push: - branches: - - main - - develop - - 'feature/**' - - 'fix/**' - pull_request: - branches: - - main - - develop - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - # ────────────────────────────────────────────────────────────────── - # 1. Code Quality, Linting & Typecheck - # ────────────────────────────────────────────────────────────────── - code-quality: - name: Code Quality & Typecheck - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install Dependencies - run: npm ci - - - name: Credential Scanner Self-Test - run: npm run security:secrets:test - - - name: Hard-Coded Credential Scan - run: npm run security:secrets - - - name: Mobile Release Boundary Self-Test - run: npm run release:mobile:boundary:test - - - name: Mobile Release Configuration Self-Test - run: npm run release:mobile:config:test - - - name: Mobile Build Configuration Self-Test - run: npm run release:mobile:build-config:test - - - name: Play Store Asset Contract - run: npm run release:play:assets - - - name: Lint Check - run: npm run lint - - - name: Typecheck All Workspaces - run: npm run typecheck - - api-server-tests: - name: .NET API Server Tests - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.302' - - - name: Restore API Test Dependencies - run: dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj - - - name: Run API Authorization and Gateway Tests - run: dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false - - edge-functions-quality: - name: Supabase Edge Functions Typecheck & Tests - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Deno 2.8.1 - uses: denoland/setup-deno@v2 - with: - deno-version: v2.8.1 - - - name: Check Every Edge Function Entrypoint - shell: bash - run: | - set -euo pipefail - for entrypoint in server/supabase/functions/*/index.ts; do - deno check --config server/supabase/functions/deno.json "$entrypoint" - done - - - name: Run Edge Function Contract Tests - run: deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions - - - name: Check Cloudflare Worker Drain - run: deno check --no-config server/cloudflare-worker/src/push-drain.ts - - - name: Run Cloudflare Worker Tests - run: deno test --no-config --allow-read server/cloudflare-worker/src/push-drain.test.ts - - # ────────────────────────────────────────────────────────────────── - # 2. Automated Test Matrix (Windows / macOS / Ubuntu) - # ────────────────────────────────────────────────────────────────── - test-matrix: - name: Test Suite (${{ matrix.os }}) - needs: code-quality - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest, ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install Dependencies - run: npm ci - - - name: Run Monorepo Test Suites (Vitest) - run: npm test - - # ────────────────────────────────────────────────────────────────── - # 3. Build Validation for All Workspaces - # ────────────────────────────────────────────────────────────────── - build-validation: - name: Build Validation (${{ matrix.target }}) - needs: code-quality - strategy: - matrix: - include: - - target: desktop - os: windows-latest - cmd: npm run build --workspace=@d3ro/desktop - - target: admin - os: ubuntu-latest - cmd: npm run build --workspace=@d3ro/admin - runs-on: ${{ matrix.os }} - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install Dependencies - run: npm ci - - - name: Build Target Workspace - run: ${{ matrix.cmd }} - - - name: Verify Desktop Renderer Bundles - if: matrix.target == 'desktop' - run: node scripts/ci/verify-desktop-renderer-bundles.mjs - - # ────────────────────────────────────────────────────────────────── - # 4. Android x86_64 artifacts and native dependency gate - # ────────────────────────────────────────────────────────────────── - mobile-android: - name: Mobile Android (universal debug + bundled universal E2E) - needs: code-quality - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Setup JDK 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '17' - - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - with: - cache-provider: basic - - - name: Install Dependencies - run: | - npm ci - npm --prefix apps/mobile-rn ci --workspaces=false - - - name: Prepare Verified Whisper Model - run: node scripts/ci/prepare-whisper-model.mjs - - - name: Test Mobile TypeScript and Jest - run: | - npm --prefix apps/mobile-rn run lint - npm --prefix apps/mobile-rn run typecheck - npm --prefix apps/mobile-rn run test:ci - - - name: Build Universal Debug, CSPRNG Test, and Bundled Universal E2E APKs - working-directory: apps/mobile-rn/android - env: - D3RO_VERSION_NAME: 0.0.0-e2e.${{ github.run_number }} - D3RO_VERSION_CODE: ${{ github.run_number }} - run: ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleE2e -PreactNativeArchitectures=arm64-v8a,x86_64 --no-daemon - - - name: Verify BuildConfig and APK Runtime Contracts - env: - D3RO_VERSION_NAME: 0.0.0-e2e.${{ github.run_number }} - D3RO_VERSION_CODE: ${{ github.run_number }} - run: | - set -euo pipefail - DEBUG_APK=apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk - E2E_APK=apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk - test -f "$DEBUG_APK" - test -f "$E2E_APK" - node scripts/ci/verify-mobile-build-config.mjs debug \ - | tee apps/mobile-rn/android/app/build/outputs/debug-build-config.json - node scripts/ci/verify-mobile-build-config.mjs e2e \ - | tee apps/mobile-rn/android/app/build/outputs/e2e-build-config.json - node scripts/ci/verify-android-artifact.mjs --mode debug --apk "$DEBUG_APK" \ - | tee apps/mobile-rn/android/app/build/outputs/debug-artifact-evidence.json - node scripts/ci/verify-android-artifact.mjs \ - --mode e2e \ - --apk "$E2E_APK" \ - --expected-version-name "$D3RO_VERSION_NAME" \ - --expected-version-code "$D3RO_VERSION_CODE" \ - | tee apps/mobile-rn/android/app/build/outputs/e2e-artifact-evidence.json - node scripts/ci/verify-android-app-links.mjs \ - | tee apps/mobile-rn/android/app/build/outputs/app-links-evidence.json - sha256sum "$DEBUG_APK" "$E2E_APK" | tee apps/mobile-rn/android/app/build/outputs/android-ci.sha256 - - - name: Upload Universal Android Artifacts - uses: actions/upload-artifact@v4 - with: - name: d3ro-mobile-android-universal-e2e - path: | - apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk - apps/mobile-rn/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk - apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk - apps/mobile-rn/android/app/build/outputs/android-ci.sha256 - apps/mobile-rn/android/app/build/outputs/*-build-config.json - apps/mobile-rn/android/app/build/outputs/*-artifact-evidence.json - apps/mobile-rn/android/app/build/outputs/app-links-evidence.json - if-no-files-found: error - - # ────────────────────────────────────────────────────────────────── - # 5. Installed bundled APK on a clean API 35 x86_64 emulator - # ────────────────────────────────────────────────────────────────── - mobile-emulator-e2e: - name: Mobile Emulator E2E (API 35) - needs: mobile-android - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup JDK 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '17' - - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - - name: Download Bundled Android Artifact - uses: actions/download-artifact@v4 - with: - name: d3ro-mobile-android-universal-e2e - path: mobile-artifact - - - name: Install Verified Maestro CLI 2.7.0 - run: | - curl -fsSL https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.7.0/maestro.zip -o /tmp/maestro.zip - echo 'a4ccab6b604617e7aef6db4f885666056eabe5cfa32befaa3bc994041b8fcbb5 /tmp/maestro.zip' | sha256sum -c - - unzip -q /tmp/maestro.zip -d "$RUNNER_TEMP/maestro" - echo "$RUNNER_TEMP/maestro/maestro/bin" >> "$GITHUB_PATH" - - - name: Run Mandatory Clean-room and Optional External-account Journeys - uses: reactivecircus/android-emulator-runner@v2 - env: - MOBILE_E2E_EMAIL: ${{ secrets.MOBILE_E2E_EMAIL }} - MOBILE_E2E_PASSWORD: ${{ secrets.MOBILE_E2E_PASSWORD }} - with: - api-level: 35 - target: google_apis - arch: x86_64 - profile: pixel_6 - disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: | - set -euo pipefail - DEBUG_APK="$(find mobile-artifact -path '*/apk/debug/app-debug.apk' -print -quit)" - TEST_APK="$(find mobile-artifact -name app-debug-androidTest.apk -print -quit)" - E2E_APK="$(find mobile-artifact -name app-e2e.apk -print -quit)" - test -n "$DEBUG_APK" - test -n "$TEST_APK" - test -n "$E2E_APK" - maestro --version - bash scripts/ci/run-mobile-csprng-instrumentation.sh "$DEBUG_APK" "$TEST_APK" - bash scripts/ci/run-mobile-emulator-gate.sh "$E2E_APK" - - - name: Upload Emulator Evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: d3ro-mobile-emulator-e2e - path: | - apps/mobile-rn/.maestro/*.junit.xml - apps/mobile-rn/.maestro-output/ - if-no-files-found: warn diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml deleted file mode 100644 index 74afdcb..0000000 --- a/.github/workflows/deploy-site.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Deploy Landing Page - -on: - push: - branches: [main] - paths: ['site/**'] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: site/package-lock.json - - - name: Install dependencies - working-directory: site - run: npm ci - - - name: Verify Mobile Release Publication Boundary - run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test - - - name: Build - working-directory: site - run: npm run build - - - uses: actions/upload-pages-artifact@v3 - with: - path: site/dist - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/payple-renew.yml b/.github/workflows/payple-renew.yml deleted file mode 100644 index 5313371..0000000 --- a/.github/workflows/payple-renew.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Payple Subscription Renewal - -on: - schedule: - # 매일 01:00 UTC (KST 10:00) - - cron: '0 1 * * *' - workflow_dispatch: {} - -jobs: - renew: - runs-on: ubuntu-latest - steps: - - name: Trigger payple-renew Edge Function - run: | - response=$(curl -s -w "\n%{http_code}" -X POST \ - "${{ secrets.SUPABASE_URL }}/functions/v1/payple-renew" \ - -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ - -H "Content-Type: application/json") - - http_code=$(echo "$response" | tail -1) - body=$(echo "$response" | head -n -1) - - echo "HTTP $http_code" - echo "$body" | jq . 2>/dev/null || echo "$body" - - if [ "$http_code" -ge 400 ]; then - echo "::error::Renewal failed with HTTP $http_code" - exit 1 - fi diff --git a/.github/workflows/release-signing-ca.yml b/.github/workflows/release-signing-ca.yml deleted file mode 100644 index 6d0fec6..0000000 --- a/.github/workflows/release-signing-ca.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Release & Code Signing CA Pipeline - -on: - workflow_dispatch: - -jobs: - build-and-sign-windows: - runs-on: windows-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Typecheck & Test - run: | - npm run typecheck - npm run test --workspace=@d3ro/api-client - - # Azure Trusted Signing (Artifact Signing) for SmartScreen Reputation - - name: Setup Azure Trusted Signing - if: env.AZURE_CLIENT_ID != '' - uses: azure/trusted-signing-action@v0.4.1 - with: - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} - endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - certificate-profile-name: ${{ secrets.AZURE_CERT_PROFILE }} - - - name: Build STT Sidecar (local transcription engine) - run: | - # Without this bundle the packaged app cannot transcribe at all. - npm run sidecar:setup --workspace=@d3ro/desktop - npm run sidecar:build --workspace=@d3ro/desktop - node scripts/ci/verify-sidecar-bundle.mjs - - - name: Build and Package Windows (NSIS + RFC 3161 TSA) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RFC3161_TSA_SERVER: "http://timestamp.digicert.com" - run: | - npm run build --workspace=@d3ro/desktop - npx electron-builder --win --config apps/desktop/electron-builder.yml - - - name: Upload Windows Artifacts - uses: actions/upload-artifact@v4 - with: - name: d3ro-voice-windows - path: apps/desktop/release/*/*.exe - - build-and-sign-macos: - runs-on: macos-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Setup Apple Developer ID Certificate - if: env.APPLE_CERTIFICATE != '' - env: - APPLE_CERTIFICATE: ${{ secrets.MAC_CSC_LINK }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} - run: | - echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 - security create-keychain -p "" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "" build.keychain - security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain - - - name: Build, Sign, and Notarize macOS (Gatekeeper CA) - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - npm run build --workspace=@d3ro/desktop - npx electron-builder --mac --config apps/desktop/electron-builder.yml - - - name: Upload macOS Artifacts - uses: actions/upload-artifact@v4 - with: - name: d3ro-voice-macos - path: apps/desktop/release/*/*.dmg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 75e3d44..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,612 +0,0 @@ -# .github/workflows/release.yml -# Multi-Platform Automated Release Pipeline for D3RO Voice Desktop & Admin - -name: Release & Packaging Pipeline - -on: - push: - tags: - - 'v*.*.*' - workflow_dispatch: - inputs: - version: - description: 'Release version (e.g. 1.0.0)' - required: true - default: '1.1.0' - -permissions: - contents: write - packages: write - -jobs: - release-preflight: - name: Release Preflight - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.302' - - - name: Setup Deno 2.8.1 - uses: denoland/setup-deno@v2 - with: - deno-version: v2.8.1 - - - name: Install JavaScript Dependencies - run: | - npm ci - npm --prefix apps/mobile-rn ci --workspaces=false - - - name: Verify Source, Security, Tests, and Play Assets - run: | - npm run version:check - if [[ "$GITHUB_REF" == refs/tags/* ]]; then - node scripts/ci/sync-version.mjs --check --tag "$GITHUB_REF_NAME" - fi - npm run release:metadata:test - npm run security:secrets:test - npm run security:secrets - npm run release:mobile:boundary:test - npm run release:mobile:config:test - npm run release:mobile:build-config:test - npm run release:play:assets - npm run lint - npm run typecheck - npm test - npm --prefix apps/mobile-rn run lint - npm --prefix apps/mobile-rn run typecheck - npm --prefix apps/mobile-rn run test:ci - - - name: Check and Test Every Supabase Edge Function - shell: bash - run: | - set -euo pipefail - for entrypoint in server/supabase/functions/*/index.ts; do - deno check --config server/supabase/functions/deno.json "$entrypoint" - done - deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions - - - name: Test .NET API Authorization and Gateway Boundaries - run: | - dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj - dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false - - # ────────────────────────────────────────────────────────────────── - # 1. Package Windows Installer (.exe & .blockmap & latest.yml) - # ────────────────────────────────────────────────────────────────── - package-windows: - name: Package Windows Desktop App - needs: release-preflight - runs-on: windows-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install Dependencies - run: npm ci - - - name: Build All Workspaces - run: | - npm run version:check - npm run typecheck - npm run build --workspace=@d3ro/desktop - - - name: Verify Desktop Renderer Bundles - run: node scripts/ci/verify-desktop-renderer-bundles.mjs - - - name: Build STT Sidecar (local transcription engine) - run: | - # Local transcription depends on the faster-whisper sidecar; a release - # that ships without it cannot transcribe at all, so build and verify - # the bundle before packaging. - npm run sidecar:setup --workspace=@d3ro/desktop - npm run sidecar:build --workspace=@d3ro/desktop - node scripts/ci/verify-sidecar-bundle.mjs - - - name: Package with Electron Builder (NSIS x64) - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - if ([string]::IsNullOrWhiteSpace($env:CSC_LINK)) { - throw 'WIN_CSC_LINK must contain the production code-signing PFX path, URL, or base64 payload.' - } - if ([string]::IsNullOrWhiteSpace($env:CSC_KEY_PASSWORD)) { - throw 'WIN_CSC_KEY_PASSWORD is required.' - } - if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT)) { - throw 'WIN_CSC_EXPECTED_SIGNER_SUBJECT is required.' - } - if ($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -match '(?i)Everything2EverythingDev') { - throw 'The local Everything2EverythingDev certificate is not a production signing identity.' - } - $releaseVersion = node -p "require('./release/product-version.json').version" - Push-Location apps/desktop - try { - npx electron-builder --win --x64 --config electron-builder.yml - node scripts/ci/verify-native-abi.mjs - if ($LASTEXITCODE -ne 0) { throw "electron-builder failed with exit code $LASTEXITCODE." } - } - finally { - Pop-Location - } - & scripts/ci/verify-windows-release-artifact.ps1 ` - -ExpectedVersion $releaseVersion ` - -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT ` - -ReleaseDirectory "apps/desktop/release/$releaseVersion" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_LINK: ${{ secrets.WIN_CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} - WIN_CSC_EXPECTED_SIGNER_SUBJECT: ${{ secrets.WIN_CSC_EXPECTED_SIGNER_SUBJECT }} - - - name: Upload Windows Build Artifacts - uses: actions/upload-artifact@v4 - with: - name: windows-release-assets - path: | - apps/desktop/release/*/*.exe - apps/desktop/release/*/*.blockmap - apps/desktop/release/*/latest.yml - - # ────────────────────────────────────────────────────────────────── - # 2. Package macOS Desktop App (.dmg & .zip & latest-mac.yml) - # ────────────────────────────────────────────────────────────────── - package-macos: - name: Package macOS Desktop App - needs: release-preflight - runs-on: macos-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install Dependencies - run: npm ci - - - name: Build All Workspaces - run: | - npm run version:check - npm run typecheck - npm run build --workspace=@d3ro/desktop - - - name: Verify Desktop Renderer Bundles - run: node scripts/ci/verify-desktop-renderer-bundles.mjs - - - name: Build STT Sidecar (local transcription engine) - run: | - # Local transcription depends on the faster-whisper sidecar; a release - # that ships without it cannot transcribe at all, so build and verify - # the bundle before packaging. - npm run sidecar:setup --workspace=@d3ro/desktop - npm run sidecar:build --workspace=@d3ro/desktop - node scripts/ci/verify-sidecar-bundle.mjs - - - name: Package with Electron Builder (DMG & ZIP arm64) - run: | - cd apps/desktop - npx electron-builder --mac --arm64 --config electron-builder.yml - node scripts/ci/verify-native-abi.mjs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_LINK: ${{ secrets.MAC_CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - - name: Upload macOS Build Artifacts - uses: actions/upload-artifact@v4 - with: - name: macos-release-assets - path: | - apps/desktop/release/*/*.dmg - apps/desktop/release/*/*.zip - apps/desktop/release/*/*.blockmap - apps/desktop/release/*/latest-mac.yml - - # ────────────────────────────────────────────────────────────────── - # 3. Package signed Android APK/AAB (arm64, production-only config) - # ────────────────────────────────────────────────────────────────── - package-android: - name: Package Android Mobile App - needs: release-preflight - runs-on: ubuntu-latest - environment: mobile-production-release - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Establish Trusted Mobile Release Identity - id: android-version - shell: bash - env: - DISPATCH_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - git fetch --no-tags origin main - if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - test "$GITHUB_REF" = "refs/heads/main" - test "$GITHUB_SHA" = "$(git rev-parse origin/main)" - VERSION_NAME="$DISPATCH_VERSION" - RELEASE_TAG="v$VERSION_NAME" - else - [[ "$GITHUB_REF" == refs/tags/v* ]] - VERSION_NAME="${GITHUB_REF_NAME#v}" - RELEASE_TAG="$GITHUB_REF_NAME" - git merge-base --is-ancestor "$GITHUB_SHA" origin/main - fi - SSOT_VERSION="$(node -p "require('./release/product-version.json').version")" - VERSION_CODE="$(node -p "require('./release/product-version.json').androidVersionCode")" - [[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] - test "$VERSION_NAME" = "$SSOT_VERSION" - test "$RELEASE_TAG" = "v$VERSION_NAME" - [[ "$VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] - test "$VERSION_CODE" -le 2100000000 - printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT" - printf 'code=%s\n' "$VERSION_CODE" >> "$GITHUB_OUTPUT" - printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT" - - - name: Verify Immutable Checkout Identity - shell: bash - run: | - set -euo pipefail - test "$GITHUB_SHA" = "$(git rev-parse HEAD)" - test -z "$(git status --porcelain --untracked-files=all)" - git rev-parse "${GITHUB_SHA}^{tree}" - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Verify Mobile Release Boundary Source Contract - run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test - - - name: Verify Mobile Release Configuration Contract - run: npm run release:mobile:config:test - - - name: Verify Mobile Build Configuration Contract - run: npm run release:mobile:build-config:test - - - name: Verify Play Store Asset Contract - run: npm run release:play:assets - - - name: Require Restricted AAB Handoff Visibility - shell: bash - env: - REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }} - run: | - set -euo pipefail - test "$REPOSITORY_VISIBILITY" = "private" - - - name: Setup JDK 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '17' - - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - - name: Install Pinned Official Bundletool - shell: bash - run: | - set -euo pipefail - curl --fail --silent --show-error --location \ - --output "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \ - https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar - printf '%s %s\n' \ - a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 \ - "$RUNNER_TEMP/bundletool-all-1.18.3.jar" | sha256sum --check --strict - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - with: - cache-provider: basic - - - name: Install Dependencies - run: | - npm ci - npm --prefix apps/mobile-rn ci --workspaces=false - - - name: Materialize Release-only Configuration - shell: bash - env: - ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} - ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }} - run: | - set -euo pipefail - umask 077 - test -n "$ANDROID_RELEASE_KEYSTORE_B64" - test -n "$ANDROID_GOOGLE_SERVICES_JSON_B64" - printf '%s' "$ANDROID_RELEASE_KEYSTORE_B64" | base64 --decode > apps/mobile-rn/android/app/release.keystore - printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_B64" | base64 --decode > apps/mobile-rn/android/app/google-services.json - test -s apps/mobile-rn/android/app/release.keystore - test -s apps/mobile-rn/android/app/google-services.json - - - name: Prepare Verified Whisper Model - run: node scripts/ci/prepare-whisper-model.mjs - - - name: Verify Production Firebase and AdMob Configuration - env: - D3RO_FIREBASE_EXPECTED_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} - D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER: ${{ secrets.FIREBASE_PROJECT_NUMBER }} - D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID: ${{ secrets.FIREBASE_MOBILESDK_APP_ID }} - run: | - export D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")" - export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")" - export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")" - npm run release:mobile:config - - - name: Test Mobile TypeScript and Jest - run: | - npm --prefix apps/mobile-rn run lint - npm --prefix apps/mobile-rn run typecheck - npm --prefix apps/mobile-rn run test:ci - - - name: Build Signed arm64 APK and AAB - working-directory: apps/mobile-rn/android - env: - D3RO_RELEASE_STORE_FILE: ${{ github.workspace }}/apps/mobile-rn/android/app/release.keystore - D3RO_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }} - D3RO_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} - D3RO_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} - D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }} - D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }} - run: | - export D3RO_ADMOB_APP_ID="$(node -p "require('../../../release/android-release-identity.json').adMobAppId")" - export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobBannerUnitId")" - export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobRewardedUnitId")" - ./gradlew :app:assembleRelease :app:bundleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon - - - name: Materialize Release Evidence Signing Key - shell: bash - env: - ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64: ${{ secrets.ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64 }} - run: | - set -euo pipefail - umask 077 - test -n "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64" - printf '%s' "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64" | base64 --decode > apps/mobile-rn/android/app/release-evidence-private.pem - test -s apps/mobile-rn/android/app/release-evidence-private.pem - - - name: Verify Release BuildConfig, Signature, ABI, Bundle, Ads, and Offline Model - shell: bash - env: - D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }} - D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }} - run: | - set -euo pipefail - D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")" - D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")" - D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")" - ANDROID_UPLOAD_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').uploadCertificateSha256")" - PLAY_APP_SIGNING_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').playAppSigningCertificateSha256")" - APK=apps/mobile-rn/android/app/build/outputs/apk/release/app-release.apk - AAB=apps/mobile-rn/android/app/build/outputs/bundle/release/app-release.aab - test -f "$APK" - test -f "$AAB" - node scripts/ci/verify-mobile-build-config.mjs release \ - | tee apps/mobile-rn/android/app/build/outputs/release-build-config.json - node scripts/ci/create-mobile-release-evidence.mjs \ - --apk "$APK" \ - --aab "$AAB" \ - --bundletool "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \ - --repository "$GITHUB_REPOSITORY" \ - --commit-sha "$GITHUB_SHA" \ - --tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \ - --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" \ - --expected-admob-app-id "$D3RO_ADMOB_APP_ID" \ - --expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \ - --expected-version-name "$D3RO_VERSION_NAME" \ - --expected-version-code "$D3RO_VERSION_CODE" \ - --private-key apps/mobile-rn/android/app/release-evidence-private.pem \ - --snapshot-dir apps/mobile-rn/android/app/build/outputs/release-snapshot - VERIFIER_SHA256="$(sha256sum scripts/ci/verify-android-artifact.mjs | awk '{print $1}')" - BUNDLETOOL_SHA256="a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29" - node scripts/ci/prepare-mobile-release-publication.mjs \ - --source-root apps/mobile-rn/android/app/build/outputs/release-snapshot \ - --apk apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.apk \ - --aab apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.aab \ - --evidence apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-evidence.json \ - --public-key release/mobile-release-evidence-public.pem \ - --destination-dir apps/mobile-rn/android/app/build/outputs/release-publication \ - --expected-admob-app-id "$D3RO_ADMOB_APP_ID" \ - --expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \ - --expected-version-name "$D3RO_VERSION_NAME" \ - --expected-version-code "$D3RO_VERSION_CODE" \ - --expected-repository "$GITHUB_REPOSITORY" \ - --expected-commit-sha "$GITHUB_SHA" \ - --expected-tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \ - --expected-git-ref "$GITHUB_REF" \ - --expected-workflow-identity "$GITHUB_WORKFLOW_REF" \ - --expected-run-id "$GITHUB_RUN_ID" \ - --expected-run-attempt "$GITHUB_RUN_ATTEMPT" \ - --expected-runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH" \ - --expected-verifier-sha256 "$VERIFIER_SHA256" \ - --expected-bundletool-sha256 "$BUNDLETOOL_SHA256" - node scripts/ci/verify-android-app-links.mjs \ - --expected-play-app-signing-cert-sha256 "$PLAY_APP_SIGNING_CERT_SHA256" \ - --forbidden-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \ - | tee apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json - sha256sum apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \ - apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \ - | tee apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt - - - name: Remove Materialized Release Secrets - if: always() - shell: bash - run: rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem - - - name: Upload Restricted Play Console AAB Handoff - uses: actions/upload-artifact@v4 - with: - name: android-play-console-handoff - path: | - apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab - apps/mobile-rn/android/app/build/outputs/release-publication/android-release-evidence.json - apps/mobile-rn/android/app/build/outputs/release-publication/android-publication-manifest.json - apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt - apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-verification.json - apps/mobile-rn/android/app/build/outputs/release-build-config.json - apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json - retention-days: 7 - if-no-files-found: error - - # ────────────────────────────────────────────────────────────────── - # 4. Build & Containerize Admin Dashboard - # ────────────────────────────────────────────────────────────────── - package-admin-docker: - name: Build & Publish Admin Docker Image - needs: release-preflight - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: actions/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry (GHCR) - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository }}/admin-console - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./apps/admin/Dockerfile - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - # ────────────────────────────────────────────────────────────────── - # 5. Create GitHub Release & Upload Checksums - # ────────────────────────────────────────────────────────────────── - publish-release: - name: Publish Official GitHub Release - needs: [package-windows, package-macos, package-android, package-admin-docker] - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Re-establish Trusted Release Identity - id: release-identity - shell: bash - env: - DISPATCH_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - git fetch --no-tags origin main - if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - test "$GITHUB_REF" = "refs/heads/main" - test "$GITHUB_SHA" = "$(git rev-parse origin/main)" - VERSION_NAME="$DISPATCH_VERSION" - RELEASE_TAG="v$VERSION_NAME" - else - [[ "$GITHUB_REF" == refs/tags/v* ]] - VERSION_NAME="${GITHUB_REF_NAME#v}" - RELEASE_TAG="$GITHUB_REF_NAME" - git merge-base --is-ancestor "$GITHUB_SHA" origin/main - fi - SSOT_VERSION="$(node -p "require('./release/product-version.json').version")" - [[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] - test "$VERSION_NAME" = "$SSOT_VERSION" - test "$RELEASE_TAG" = "v$VERSION_NAME" - printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT" - printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT" - - - name: Setup Node.js 24 - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - - - name: Download Windows Artifacts - uses: actions/download-artifact@v4 - with: - name: windows-release-assets - path: release-dist/ - - - name: Download macOS Artifacts - uses: actions/download-artifact@v4 - with: - name: macos-release-assets - path: release-dist/ - - - name: Generate SHA-256 Checksums - run: | - set -euo pipefail - find release-dist -type f ! -name SHA256SUMS.txt -print0 \ - | sort -z \ - | xargs -0 sha256sum > release-dist/SHA256SUMS.txt - cat release-dist/SHA256SUMS.txt - - - name: Extract Canonical Release Notes - run: >- - node scripts/ci/extract-release-notes.mjs - --version "${{ steps.release-identity.outputs.name }}" - --output release-notes.md - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: | - release-dist/* - draft: false - prerelease: false - body_path: release-notes.md - generate_release_notes: false - tag_name: ${{ steps.release-identity.outputs.tag }} - fail_on_unmatched_files: true - overwrite_files: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish to Forgejo Release and Update Feed - env: - FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} - FORGEJO_RELEASE_TAG: ${{ steps.release-identity.outputs.tag }} - FORGEJO_RELEASE_DIR: release-dist - run: node scripts/ci/publish-forgejo-release.mjs diff --git a/scripts/ci/verify-mobile-release-boundary.mjs b/scripts/ci/verify-mobile-release-boundary.mjs index 457949a..2ddeda8 100644 --- a/scripts/ci/verify-mobile-release-boundary.mjs +++ b/scripts/ci/verify-mobile-release-boundary.mjs @@ -1,6 +1,5 @@ import { generateKeyPairSync } from 'node:crypto' import { spawnSync } from 'node:child_process' -import { createRequire } from 'node:module' import { existsSync, linkSync, @@ -32,8 +31,6 @@ import { } 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' @@ -101,15 +98,6 @@ function listScripts(directory) { } 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', @@ -125,71 +113,6 @@ function verifySourceContracts() { } 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 gitlabWorkflow = readWorkspaceFile('.gitlab-ci.yml') const mobileProductionJobStart = gitlabWorkflow.indexOf('mobile-production-release:\n') const publishReleaseJobStart = gitlabWorkflow.indexOf('\npublish-release:\n') @@ -361,7 +284,6 @@ function verifySourceContracts() { 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', @@ -370,26 +292,25 @@ function verifySourceContracts() { 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') + const siteDeployWorkflow = readWorkspaceFile('.forgejo/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(''), 'api_static_release_exclusion_missing') - for (const staleAsset of ['index-D7M5UQvT.js', 'index-JlYFxlAJ.js']) { - assert(apiProject.includes(``), `api_stale_marketing_asset_publishable_${staleAsset}`) - } + // Installers ship only through the Forgejo feed and public pages only from site/, + // so the API must not serve static files at all. 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') + assert(!apiProgram.includes('UseStaticFiles'), 'api_serves_static_files') + // 사이트는 Android·iOS 설치 파일을 내려주지 않는다. 다운로드 버튼은 Windows 설치 파일 하나뿐이고 + // 모바일은 '준비 중' 문구로만 안내한다(site/src/sections/Download.tsx, Hero.tsx). + for (const sitePath of [ + 'site/src/hooks/useClientOS.ts', + 'site/src/sections/Hero.tsx', + 'site/src/sections/Download.tsx', + 'site/src/release.ts', + ]) { + const siteSource = readWorkspaceFile(sitePath) + assert(!/\.(?:apk|aab)\b/i.test(siteSource), `site_mobile_package_link_${sitePath}`) + assert(!/attachments\/[0-9a-f-]+/i.test(siteSource), `site_attachment_link_${sitePath}`) + assert(!/play\.google\.com\/store|apps\.apple\.com/i.test(siteSource), `site_store_link_${sitePath}`) + } } function expectFailure(label, operation, expectedCode) { @@ -800,14 +721,6 @@ function verifyNegativeAndMaterializationTests() { 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) diff --git a/scripts/ci/verify-release-metadata.mjs b/scripts/ci/verify-release-metadata.mjs index 7e0438d..0536dea 100644 --- a/scripts/ci/verify-release-metadata.mjs +++ b/scripts/ci/verify-release-metadata.mjs @@ -33,11 +33,6 @@ function loadSurfaces(readSurface = read) { publisher: readSurface('scripts/ci/publish-gitlab-release.mjs'), forgejoPublisher: readSurface('scripts/ci/publish-forgejo-release.mjs'), gitlab: readSurface('.gitlab-ci.yml'), - github: readSurface('.github/workflows/release.yml'), - githubMac: readSurface('.github/workflows/build-mac.yml'), - githubSigning: readSurface('.github/workflows/release-signing-ca.yml'), - forgejoLinux: readSurface('.forgejo/workflows/deploy-site.yml'), - forgejoWindows: readSurface('.forgejo/workflows/deploy-site-windows.yml'), forgejoRelease: readSurface('.forgejo/workflows/release.yml'), changelog: readSurface('CHANGELOG.md'), } @@ -208,7 +203,7 @@ function validate(surfaces) { for (const [name, workflow] of [ ['gitlab', surfaces.gitlab], - ['github', surfaces.github], + ['forgejo', surfaces.forgejoRelease], ]) { fail(workflow.includes('sync-version.mjs'), `${name}_version_gate_missing`) fail(workflow.includes('release/product-version.json'), `${name}_product_metadata_missing`) @@ -221,10 +216,8 @@ function validate(surfaces) { fail(/tags:/.test(surfaces.forgejoRelease), 'forgejo_release_workflow_tag_trigger_missing') fail(surfaces.forgejoRelease.includes('sync-version.mjs'), 'forgejo_release_workflow_version_gate_missing') - fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubMac), 'legacy_mac_tag_trigger_enabled') - fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubSigning), 'legacy_signing_tag_trigger_enabled') - fail(!surfaces.forgejoLinux.includes('sync-and-publish-forgejo-release'), 'forgejo_linux_legacy_release_sync') - fail(!surfaces.forgejoWindows.includes('sync-and-publish-forgejo-release'), 'forgejo_windows_legacy_release_sync') + // CI 정본은 Forgejo 한 벌이다(REFACTOR_POLICY W3-7). GitHub 원격이 없어 .github 워크플로는 실행되지 않는다. + fail(!existsSync(join(root, '.github/workflows')), 'github_workflows_reintroduced') fail(!existsSync(join(root, 'apps/mobile-rn/src/lib/update-manager.ts')), 'unsafe_mobile_update_manager_present') fail( existsSync(join(root, `apps/mobile-rn/metadata/android/ko-KR/changelogs/${metadata.androidVersionCode}.txt`)),