// scripts/ci/set-forgejo-secrets.mjs // Forgejo Actions 저장소 시크릿을 점검하거나 등록한다. // // 배경: 데스크톱 릴리스 워크플로(.forgejo/workflows/release.yml)는 아래 시크릿이 // 없으면 fail-closed로 중단한다. 저장소에 시크릿이 하나도 없으면 태그를 올려도 // 설치본이 게시되지 않는다(실측: run 49/51 모두 서명 가드에서 실패). // // 사용: // node scripts/ci/set-forgejo-secrets.mjs --check # 현재 상태만 확인 // node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write // // 값은 출력하지 않는다(이름/존재 여부/길이만). FORGEJO_TOKEN(쓰기 스코프 필요)은 // .env 또는 환경변수에서 읽는다. import { readFileSync, existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const REQUIRED = [ 'WIN_CSC_LINK', 'WIN_CSC_KEY_PASSWORD', 'WIN_CSC_EXPECTED_SIGNER_SUBJECT', 'FORGEJO_TOKEN', ] const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const args = process.argv.slice(2) const write = args.includes('--write') /** .env(있으면) + 환경변수에서 값 조회. 값은 로그에 절대 남기지 않는다. */ function readEnv() { const values = { ...process.env } const envPath = join(root, '.env') if (existsSync(envPath)) { for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) { if (!/^[A-Z0-9_]+=/.test(line)) continue const index = line.indexOf('=') const key = line.slice(0, index) if (values[key]) continue values[key] = line.slice(index + 1).trim() } } return values } const env = readEnv() const token = env.FORGEJO_TOKEN?.trim() const server = (env.GIT_SERVER_URL?.trim() || 'https://git.chanpaca.net').replace(/\/$/, '') const owner = env.GIT_USERNAME?.trim() || 'yunchan' const repo = env.GIT_REPO_NAME?.trim() || 'd3ro-voice' if (!token) { console.error('FORGEJO_TOKEN이 필요합니다 (.env 또는 환경변수).') process.exit(1) } const apiBase = `${server}/api/v1/repos/${owner}/${repo}/actions/secrets` const headers = { Authorization: `token ${token}` } async function listSecrets() { const response = await fetch(apiBase, { headers }) if (!response.ok) { throw new Error(`시크릿 목록 조회 실패: HTTP ${response.status}`) } const body = await response.json() return new Set((Array.isArray(body) ? body : []).map((item) => item.name)) } async function putSecret(name, value) { const response = await fetch(`${apiBase}/${name}`, { method: 'PUT', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ data: value }), }) if (!response.ok && response.status !== 201 && response.status !== 204) { throw new Error(`${name} 등록 실패: HTTP ${response.status}`) } } const existing = await listSecrets() console.log(`저장소: ${owner}/${repo} (${server})`) console.log(`시크릿 API: ${apiBase}\n`) let missing = 0 for (const name of REQUIRED) { const present = existing.has(name) if (present) { console.log(` [x] ${name} — 등록됨`) continue } missing += 1 const value = env[name]?.trim() console.log(` [ ] ${name} — 없음${value ? ` (환경/.env에 값 있음, 길이 ${value.length})` : ' (값 없음)'}`) } if (missing === 0) { console.log('\n모든 릴리스 시크릿이 준비되었습니다.') process.exit(0) } if (!write) { console.log( [ `\n누락 ${missing}건. 값이 준비되면 다음으로 등록한다:`, ' node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write', '', 'WIN_CSC_LINK는 public-trust Authenticode PFX를 base64로 인코딩한 값이어야 하며,', 'WIN_CSC_EXPECTED_SIGNER_SUBJECT는 그 인증서의 정확한 subject 문자열이어야 한다.', '(개발용 Everything2EverythingDev 인증서는 production으로 인정되지 않는다.)', ].join('\n'), ) process.exit(missing === 0 ? 0 : 2) } let written = 0 for (const name of REQUIRED) { if (existing.has(name)) continue const value = env[name]?.trim() if (!value) { console.log(` 건너뜀: ${name} (값 없음)`) continue } await putSecret(name, value) written += 1 console.log(` 등록: ${name}`) } const after = await listSecrets() const stillMissing = REQUIRED.filter((name) => !after.has(name)) console.log(`\n등록 ${written}건. 남은 누락: ${stillMissing.length ? stillMissing.join(', ') : '없음'}`) process.exit(stillMissing.length === 0 ? 0 : 2)