ci: run CI only on Forgejo and delete the never-run GitHub workflows (WS-D)

The repository has Forgejo and GitLab remotes but no GitHub remote, so
nothing under .github/workflows ever ran - including the daily Payple
renewal job, which means Payple subscriptions were not being renewed.

- Port payple-renew (daily cron + manual dispatch) to Forgejo. Requires the
  Forgejo secrets SUPABASE_URL and CRON_SECRET.
- Port the CI checks (quality incl. version:check and contract:check, build,
  mobile quality, edge functions, API tests) to .forgejo/workflows/ci.yml.
  macOS and Android emulator jobs are dropped: no macOS runner here, and the
  signed Android release stays on GitLab mobile-production-release.
- Keep one site deploy workflow (Linux, Cloudflare Pages); remove the
  manual Windows duplicate. The mobile release boundary self-test runs there.
- Release verifiers read the Forgejo/GitLab workflows, fail if .github
  workflows come back, and check the rewritten site offers no mobile package.

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-7 and W3-8.
This commit is contained in:
Yun Chan 2026-09-26 15:49:00 +09:00
parent cd9d199dbf
commit dc43884e3e
13 changed files with 269 additions and 1370 deletions

188
.forgejo/workflows/ci.yml Normal file
View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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://<project-ref>.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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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('<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}`)
}
// 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)

View file

@ -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`)),