feat(release): ship an install path that needs no code-signing certificate
Some checks failed
deploy-site / deploy (push) Failing after 4m9s
Some checks failed
deploy-site / deploy (push) Failing after 4m9s
Installers could not be published at all: the signing certificate does not exist yet, and the release pipelines stop at their signing guard. Users had no way to install a fixed build, so the product was effectively stuck behind a certificate that takes weeks to obtain. There is also a second, independent blocker: the download feed sits behind Cloudflare, which rejects any upload body over about 100 MiB, and the app with its speech engine exceeds that even when signed. A portable channel now publishes what can actually be delivered today: the app compressed into 95 MiB 7z volumes (162 MiB total instead of 243 MiB), a Scoop bucket for a normal install and uninstall experience, and a verifiable manual installer script. It is deliberately separate from the auto-update feed, needs no certificate, and refuses to overwrite an already published version.
This commit is contained in:
parent
a85ab799a3
commit
c35c6f3e95
13 changed files with 805 additions and 1 deletions
153
scripts/ci/publish-portable-release.mjs
Normal file
153
scripts/ci/publish-portable-release.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// scripts/ci/publish-portable-release.mjs
|
||||
// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를
|
||||
// Forgejo Generic Registry에 게시한다.
|
||||
//
|
||||
// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다.
|
||||
// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다.
|
||||
//
|
||||
// 경로:
|
||||
// .../generic/d3ro-voice/portable-<version>/<륨>.7z.00N
|
||||
// .../generic/d3ro-voice/portable-<version>/portable.json
|
||||
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
|
||||
// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias)
|
||||
//
|
||||
// 사용:
|
||||
// node scripts/ci/build-portable.mjs
|
||||
// node --env-file-if-exists=.env scripts/ci/publish-portable-release.mjs [--check]
|
||||
|
||||
import credentialHelpers from '../lib/credentials.cjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const { forgejoAuthorization } = credentialHelpers
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const check = process.argv.includes('--check') || process.env.PORTABLE_PUBLISH_DRY_RUN === '1'
|
||||
|
||||
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
|
||||
const version = JSON.parse(
|
||||
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
|
||||
).version
|
||||
|
||||
const releaseDir = join(root, 'apps', 'desktop', 'release', version)
|
||||
const index = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
|
||||
const installerPath = join(root, 'scripts', 'install', 'install-d3ro-voice.ps1')
|
||||
|
||||
if (index.version !== version) {
|
||||
console.error(
|
||||
`[portable] portable.json 버전(${index.version})이 product-version.json(${version})과 다릅니다. build-portable.mjs를 다시 실행하세요.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(installerPath)) {
|
||||
console.error(`[portable] 설치 스크립트가 없습니다: ${installerPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 게시 전 해시 재검증 — 파일이 바뀌었는데 인덱스가 낡으면 불일치 배포가 된다.
|
||||
const payloads = []
|
||||
for (const volume of index.volumes) {
|
||||
const path = join(releaseDir, volume.name)
|
||||
if (!existsSync(path)) {
|
||||
console.error(`[portable] 볼륨이 없습니다: ${path}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const bytes = await readFile(path)
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
if (sha256 !== volume.sha256) {
|
||||
console.error(
|
||||
`[portable] sha256 불일치 (${volume.name}): index=${volume.sha256} actual=${sha256}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
payloads.push({ name: volume.name, bytes, contentType: 'application/octet-stream' })
|
||||
}
|
||||
|
||||
payloads.push({
|
||||
name: 'portable.json',
|
||||
bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'),
|
||||
contentType: 'application/json',
|
||||
})
|
||||
payloads.push({
|
||||
name: 'install-d3ro-voice.ps1',
|
||||
bytes: await readFile(installerPath),
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
|
||||
const authorization = forgejoAuthorization()
|
||||
const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`]
|
||||
|
||||
async function forgejoFetch(url, init = {}) {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: { Authorization: authorization, ...(init.headers ?? {}) },
|
||||
})
|
||||
}
|
||||
|
||||
async function upload(url, body, contentType) {
|
||||
if (check) {
|
||||
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`)
|
||||
return
|
||||
}
|
||||
// Forgejo의 generic registry는 HEAD를 405로 거부한다(실측) → Range GET으로 크기만 읽는다.
|
||||
const probe = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
|
||||
const contentRange = probe?.headers.get('content-range')
|
||||
const remoteLength = contentRange ? Number(contentRange.split('/')[1]) : NaN
|
||||
if (probe?.ok && Number.isFinite(remoteLength)) {
|
||||
if (remoteLength === body.length) {
|
||||
console.log(`[portable] 이미 동일한 파일이 있습니다(건너뜀): ${url}`)
|
||||
return
|
||||
}
|
||||
// 볼륨은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
|
||||
if (url.includes(`/portable-${version}/`) && url.includes('.7z.')) {
|
||||
console.error(
|
||||
`[portable] ${version} 볼륨에 다른 바이트가 이미 있습니다: ${url}\n` +
|
||||
' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
await forgejoFetch(url, { method: 'DELETE' }).catch(() => null)
|
||||
}
|
||||
const response = await forgejoFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body,
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[portable] 업로드 실패 (HTTP ${response.status}): ${url}\n` +
|
||||
' HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`[portable] uploaded ${url}`)
|
||||
}
|
||||
|
||||
for (const base of bases) {
|
||||
for (const payload of payloads) {
|
||||
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
[
|
||||
'',
|
||||
`[portable] 게시 ${check ? '(check 모드 — 실제 업로드 없음)' : '완료'}: ${version}`,
|
||||
` 볼륨 : ${index.volumeCount}개 / 합계 ${(index.totalSize / 1048576).toFixed(1)}MiB`,
|
||||
` 인덱스 : ${FEED}/portable-latest/portable.json`,
|
||||
` 스크립트: ${FEED}/portable-latest/install-d3ro-voice.ps1`,
|
||||
'',
|
||||
' 설치(Scoop, 권장):',
|
||||
' scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git',
|
||||
' scoop install d3ro/d3ro-voice',
|
||||
'',
|
||||
' 수동 설치(7-Zip 필요):',
|
||||
` irm ${FEED}/portable-latest/install-d3ro-voice.ps1 | iex`,
|
||||
'',
|
||||
' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.',
|
||||
].join('\n'),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue