| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 |
- import chalk from 'chalk'
- import { config as loadEnv } from 'dotenv'
- import { createReadStream, promises as fs } from 'node:fs'
- import path from 'node:path'
- import { Transform } from 'node:stream'
- import { fileURLToPath } from 'node:url'
- import prettyBytes from 'pretty-bytes'
- import {
- getFullVersion,
- getReleaseTag,
- getReleaseVersion,
- isReleaseArtifactFile,
- resolveGithubRepository,
- } from './release-config.mjs'
- import {
- attachDiagnostic,
- buildDebugPayload,
- buildDiagnostic,
- formatDiagnosticSummary,
- formatRetrySummary,
- } from './upload-diagnostics.mjs'
- import { createUploadProgressTracker } from './upload-progress.mjs'
- loadEnv()
- const __filename = fileURLToPath(import.meta.url)
- const __dirname = path.dirname(__filename)
- const rootDir = path.normalize(path.join(__dirname, '..'))
- const distDir = path.join(rootDir, 'dist')
- const dryRun = process.env.DRY_RUN === '1' || process.argv.includes('--dry-run')
- const token = process.env.GH_TOKEN
- const repository = resolveGithubRepository(process.env)
- const releaseTag = getReleaseTag(process.env)
- const releaseVersion = getReleaseVersion()
- const fullVersion = getFullVersion()
- const retryAttempts = Math.max(
- 1,
- Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_ATTEMPTS, 10) || 3,
- )
- const retryBaseDelayMs = Math.max(
- 250,
- Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_BASE_DELAY_MS, 10) || 1500,
- )
- const retryMaxDelayMs = Math.max(
- retryBaseDelayMs,
- Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_MAX_DELAY_MS, 10) || 10000,
- )
- const retryableStatusCodes = new Set([ 408, 409, 425, 429, 500, 502, 503, 504 ])
- const debugDiagnostics = process.env.RELEASE_UPLOAD_DEBUG === '1'
- function log(message) {
- console.log(`[release:upload] ${message}`)
- }
- function logFileList(files) {
- log('files:')
- files.forEach((file) => {
- console.log(` - ${file.name} (${prettyBytes(file.size)})`)
- })
- }
- function getArtifactVersion(fileName) {
- const match = /-v(\d+\.\d+\.\d+\.\d+)-/.exec(fileName)
- return match ? match[1] : null
- }
- function sleep(ms) {
- return new Promise((resolve) => {
- setTimeout(resolve, ms)
- })
- }
- function getRetryDelayMs(attempt) {
- return Math.min(retryBaseDelayMs * 2 ** Math.max(attempt - 1, 0), retryMaxDelayMs)
- }
- function formatRetryDelay(ms) {
- return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`
- }
- function isRetryableStatus(status) {
- return retryableStatusCodes.has(status)
- }
- function isRetryableFetchError(error) {
- if (!(error instanceof Error)) {
- return false
- }
- const code =
- typeof error.cause === 'object' && error.cause !== null && 'code' in error.cause
- ? String(error.cause.code || '')
- : ''
- const message = `${error.message} ${code}`.toLowerCase()
- return (
- message.includes('fetch failed') ||
- message.includes('network') ||
- message.includes('timeout') ||
- message.includes('econnreset') ||
- message.includes('eai_again') ||
- message.includes('enotfound') ||
- message.includes('econnrefused') ||
- message.includes('socket')
- )
- }
- function getProgressSnapshot(progressTracker) {
- return progressTracker?.getSnapshot() ?? null
- }
- function logDiagnosticDebug(error) {
- if (!debugDiagnostics) {
- return
- }
- const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null
- const payload = buildDebugPayload(diagnostic, error)
- console.error(chalk.gray('[release:upload] debug diagnostic:'))
- console.error(chalk.gray(JSON.stringify(payload, null, 2)))
- }
- async function readReleaseFiles() {
- const entries = await fs.readdir(distDir, { withFileTypes: true })
- const files = entries.filter((entry) => entry.isFile())
- const mismatchedVersionedFiles = files
- .map((entry) => entry.name)
- .filter((fileName) => {
- const artifactVersion = getArtifactVersion(fileName)
- return artifactVersion && artifactVersion !== fullVersion
- })
- if (mismatchedVersionedFiles.length > 0) {
- throw new Error(
- `Cannot prepare GitHub Release assets for version ${fullVersion}.\n` +
- `Found old build artifacts in dist/: ${mismatchedVersionedFiles.join(', ')}\n` +
- `This usually means src/version.json was updated after the last package build, so only latest*.yml still matches.\n` +
- `Please rebuild the app for version ${fullVersion}, or clean dist/ before uploading.`,
- )
- }
- // Keep the asset picker strict so repeated uploads remain deterministic across machines.
- const selectedFiles = files
- .filter((entry) => isReleaseArtifactFile(entry.name, fullVersion))
- .map((entry) => ({
- name: entry.name,
- filePath: path.join(distDir, entry.name),
- }))
- .sort((a, b) => a.name.localeCompare(b.name))
- return Promise.all(
- selectedFiles.map(async (file) => ({
- ...file,
- size: (await fs.stat(file.filePath)).size,
- })),
- )
- }
- async function githubRequest(
- pathname,
- { method = 'GET', body, headers = {}, stage = 'github-request', fileName = null } = {},
- ) {
- const requestUrl = `https://api.github.com${pathname}`
- for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
- let response
- try {
- response = await fetch(requestUrl, {
- method,
- headers: {
- Accept: 'application/vnd.github+json',
- Authorization: `Bearer ${token}`,
- 'User-Agent': 'SwitchHosts-release-uploader',
- 'X-GitHub-Api-Version': '2022-11-28',
- ...headers,
- },
- body,
- })
- } catch (error) {
- const diagnostic = buildDiagnostic({
- attempt,
- error,
- fileName,
- maxAttempts: retryAttempts,
- method,
- retryable: isRetryableFetchError(error),
- stage,
- target: pathname,
- })
- if (attempt >= retryAttempts || !isRetryableFetchError(error)) {
- throw attachDiagnostic(error, diagnostic)
- }
- const delayMs = getRetryDelayMs(attempt)
- log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs)))
- await sleep(delayMs)
- continue
- }
- if (!response.ok) {
- const text = await response.text()
- const error = new Error(`${method} ${pathname} failed: ${response.status} ${text}`)
- const diagnostic = buildDiagnostic({
- attempt,
- error,
- fileName,
- httpStatus: response.status,
- maxAttempts: retryAttempts,
- method,
- retryable: isRetryableStatus(response.status),
- stage,
- target: pathname,
- })
- if (attempt < retryAttempts && isRetryableStatus(response.status)) {
- const delayMs = getRetryDelayMs(attempt)
- log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs)))
- await sleep(delayMs)
- continue
- }
- throw attachDiagnostic(error, diagnostic)
- }
- if (response.status === 204) {
- return null
- }
- return response.json()
- }
- throw new Error(`${method} ${pathname} failed after ${retryAttempts} attempts.`)
- }
- async function findReleaseByTag() {
- let page = 1
- const maxPages = 20
- while (page <= maxPages) {
- // The list API is used here because draft releases are not reliably addressable
- // through the single-release-by-tag endpoint.
- const releases = await githubRequest(
- `/repos/${repository.owner}/${repository.repo}/releases?per_page=100&page=${page}`,
- {
- stage: 'find-release',
- },
- )
- const found = releases.find((release) => release.tag_name === releaseTag)
- if (found) {
- return found
- }
- if (releases.length < 100) {
- return null
- }
- page += 1
- }
- }
- async function createDraftRelease() {
- return githubRequest(`/repos/${repository.owner}/${repository.repo}/releases`, {
- method: 'POST',
- stage: 'create-release',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- tag_name: releaseTag,
- name: releaseTag,
- draft: true,
- prerelease: false,
- generate_release_notes: false,
- }),
- })
- }
- function getUploadUrl(release) {
- return release.upload_url.replace(/\{.*$/, '')
- }
- async function deleteAsset(assetId, assetName) {
- await githubRequest(`/repos/${repository.owner}/${repository.repo}/releases/assets/${assetId}`, {
- method: 'DELETE',
- stage: 'delete-asset',
- fileName: assetName,
- })
- }
- async function tryDeleteAssetByName(releaseId, assetName) {
- try {
- const assets = await githubRequest(
- `/repos/${repository.owner}/${repository.repo}/releases/${releaseId}/assets?per_page=100`,
- { stage: 'list-assets', fileName: assetName },
- )
- const match = assets?.find((asset) => asset.name === assetName)
- if (match) {
- await deleteAsset(match.id, assetName)
- }
- } catch (_) {
- // Best-effort cleanup — don't block the retry if this fails.
- }
- }
- async function uploadAsset(uploadUrl, file, { fileIndex, releaseId, progressTracker } = {}) {
- const url = new URL(uploadUrl)
- url.searchParams.set('name', file.name)
- progressTracker?.startFile(file, fileIndex)
- for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
- const fileStream = createReadStream(file.filePath)
- const trackedStream = fileStream.pipe(
- new Transform({
- transform(chunk, encoding, callback) {
- progressTracker?.advance(chunk.byteLength)
- callback(null, chunk)
- },
- }),
- )
- let response
- try {
- response = await fetch(url, {
- method: 'POST',
- headers: {
- Accept: 'application/vnd.github+json',
- Authorization: `Bearer ${token}`,
- 'Content-Length': String(file.size),
- 'Content-Type': 'application/octet-stream',
- 'User-Agent': 'SwitchHosts-release-uploader',
- 'X-GitHub-Api-Version': '2022-11-28',
- },
- body: trackedStream,
- duplex: 'half',
- })
- } catch (error) {
- fileStream.destroy()
- trackedStream.destroy()
- const diagnostic = buildDiagnostic({
- attempt,
- error,
- fileIndex,
- fileName: file.name,
- maxAttempts: retryAttempts,
- method: 'POST',
- progressSnapshot: getProgressSnapshot(progressTracker),
- retryable: isRetryableFetchError(error),
- stage: 'upload-asset',
- target: url,
- })
- if (attempt < retryAttempts && isRetryableFetchError(error)) {
- const delayMs = getRetryDelayMs(attempt)
- await tryDeleteAssetByName(releaseId, file.name)
- progressTracker?.resetCurrentFile()
- progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`)
- await sleep(delayMs)
- continue
- }
- progressTracker?.fail(file.name)
- throw attachDiagnostic(error, diagnostic)
- }
- if (!response.ok) {
- fileStream.destroy()
- trackedStream.destroy()
- const text = await response.text()
- const error = new Error(`Upload failed for ${file.name}: ${response.status} ${text}`)
- const diagnostic = buildDiagnostic({
- attempt,
- error,
- fileIndex,
- fileName: file.name,
- httpStatus: response.status,
- maxAttempts: retryAttempts,
- method: 'POST',
- progressSnapshot: getProgressSnapshot(progressTracker),
- retryable: isRetryableStatus(response.status),
- stage: 'upload-asset',
- target: url,
- })
- if (attempt < retryAttempts && isRetryableStatus(response.status)) {
- const delayMs = getRetryDelayMs(attempt)
- await tryDeleteAssetByName(releaseId, file.name)
- progressTracker?.resetCurrentFile()
- progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`)
- await sleep(delayMs)
- continue
- }
- progressTracker?.fail(file.name)
- throw attachDiagnostic(error, diagnostic)
- }
- progressTracker?.completeFile()
- return response.json()
- }
- const exhaustedError = new Error(`Upload failed for ${file.name} after ${retryAttempts} attempts.`)
- progressTracker?.fail(file.name)
- throw attachDiagnostic(
- exhaustedError,
- buildDiagnostic({
- attempt: retryAttempts,
- error: exhaustedError,
- fileIndex,
- fileName: file.name,
- maxAttempts: retryAttempts,
- method: 'POST',
- progressSnapshot: getProgressSnapshot(progressTracker),
- retryable: false,
- stage: 'upload-asset',
- target: url,
- }),
- )
- }
- async function main() {
- const files = await readReleaseFiles()
- const totalFiles = files.length
- const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
- if (files.length === 0) {
- throw new Error(`No release artifacts found in ${distDir} for version ${fullVersion}.`)
- }
- log(`repository: ${repository.fullName}`)
- log(`release version: ${releaseVersion}`)
- log(`release tag: ${releaseTag}`)
- log(`artifacts: ${totalFiles} files, ${prettyBytes(totalBytes)}`)
- logFileList(files)
- if (dryRun) {
- log('dry run enabled, skipping GitHub API calls.')
- return
- }
- if (!token) {
- throw new Error('GH_TOKEN is required unless DRY_RUN=1 is set.')
- }
- let release = await findReleaseByTag()
- if (!release) {
- log(`release ${releaseTag} not found, creating draft release...`)
- release = await createDraftRelease()
- } else {
- log(`using existing release ${releaseTag} (draft=${release.draft}, prerelease=${release.prerelease})`)
- }
- const uploadUrl = getUploadUrl(release)
- const existingAssets = new Map(release.assets.map((asset) => [asset.name, asset]))
- const progressTracker = createUploadProgressTracker({
- totalBytes,
- totalFiles,
- log,
- })
- const logUploadStatus = (message) => progressTracker.interrupt(`[release:upload] ${message}`)
- for (const [ index, file ] of files.entries()) {
- const existingAsset = existingAssets.get(file.name)
- if (existingAsset) {
- // Replace same-name assets so different machines can safely append
- // or refresh artifacts for the same draft release.
- logUploadStatus(`replacing existing asset ${file.name}`)
- await deleteAsset(existingAsset.id, file.name)
- } else {
- logUploadStatus(`uploading new asset ${file.name}`)
- }
- await uploadAsset(uploadUrl, file, {
- fileIndex: index + 1,
- releaseId: release.id,
- progressTracker,
- })
- }
- progressTracker.finish()
- log(`done: ${release.html_url}`)
- }
- try {
- await main()
- } catch (error) {
- const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null
- const message = diagnostic ? formatDiagnosticSummary(diagnostic) : error instanceof Error ? error.message : String(error)
- console.error(chalk.red(`[release:upload] ${message}`))
- logDiagnosticDebug(error)
- process.exit(1)
- }
|