docs(release): record why tagged releases publish nothing
Some checks failed
deploy-site / deploy (push) Failing after 31s

Two tagged release pipelines failed and no installer has been published since
1.1.0. The cause is not in the build: the release repository has no Actions
secrets at all, so every run stops at the signing guard.

A helper now reports which release secrets are missing and registers them once
the signing material exists, and the release guide documents the required
values, the Forgejo-side check, and how to re-run a pipeline for an existing
tag without recreating it.
This commit is contained in:
Yun Chan 2026-09-18 04:37:05 +09:00
parent 7e1972a315
commit a85ab799a3
4 changed files with 178 additions and 14 deletions

View file

@ -0,0 +1,131 @@
// 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)