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

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)