upload-release.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import chalk from 'chalk'
  2. import { config as loadEnv } from 'dotenv'
  3. import { createReadStream, promises as fs } from 'node:fs'
  4. import path from 'node:path'
  5. import { Transform } from 'node:stream'
  6. import { fileURLToPath } from 'node:url'
  7. import prettyBytes from 'pretty-bytes'
  8. import {
  9. getFullVersion,
  10. getReleaseTag,
  11. getReleaseVersion,
  12. isReleaseArtifactFile,
  13. resolveGithubRepository,
  14. } from './release-config.mjs'
  15. import {
  16. attachDiagnostic,
  17. buildDebugPayload,
  18. buildDiagnostic,
  19. formatDiagnosticSummary,
  20. formatRetrySummary,
  21. } from './upload-diagnostics.mjs'
  22. import { createUploadProgressTracker } from './upload-progress.mjs'
  23. loadEnv()
  24. const __filename = fileURLToPath(import.meta.url)
  25. const __dirname = path.dirname(__filename)
  26. const rootDir = path.normalize(path.join(__dirname, '..'))
  27. const distDir = path.join(rootDir, 'dist')
  28. const dryRun = process.env.DRY_RUN === '1' || process.argv.includes('--dry-run')
  29. const token = process.env.GH_TOKEN
  30. const repository = resolveGithubRepository(process.env)
  31. const releaseTag = getReleaseTag(process.env)
  32. const releaseVersion = getReleaseVersion()
  33. const fullVersion = getFullVersion()
  34. const retryAttempts = Math.max(
  35. 1,
  36. Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_ATTEMPTS, 10) || 3,
  37. )
  38. const retryBaseDelayMs = Math.max(
  39. 250,
  40. Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_BASE_DELAY_MS, 10) || 1500,
  41. )
  42. const retryMaxDelayMs = Math.max(
  43. retryBaseDelayMs,
  44. Number.parseInt(process.env.RELEASE_UPLOAD_RETRY_MAX_DELAY_MS, 10) || 10000,
  45. )
  46. const retryableStatusCodes = new Set([ 408, 409, 425, 429, 500, 502, 503, 504 ])
  47. const debugDiagnostics = process.env.RELEASE_UPLOAD_DEBUG === '1'
  48. function log(message) {
  49. console.log(`[release:upload] ${message}`)
  50. }
  51. function logFileList(files) {
  52. log('files:')
  53. files.forEach((file) => {
  54. console.log(` - ${file.name} (${prettyBytes(file.size)})`)
  55. })
  56. }
  57. function getArtifactVersion(fileName) {
  58. const match = /-v(\d+\.\d+\.\d+\.\d+)-/.exec(fileName)
  59. return match ? match[1] : null
  60. }
  61. function sleep(ms) {
  62. return new Promise((resolve) => {
  63. setTimeout(resolve, ms)
  64. })
  65. }
  66. function getRetryDelayMs(attempt) {
  67. return Math.min(retryBaseDelayMs * 2 ** Math.max(attempt - 1, 0), retryMaxDelayMs)
  68. }
  69. function formatRetryDelay(ms) {
  70. return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`
  71. }
  72. function isRetryableStatus(status) {
  73. return retryableStatusCodes.has(status)
  74. }
  75. function isRetryableFetchError(error) {
  76. if (!(error instanceof Error)) {
  77. return false
  78. }
  79. const code =
  80. typeof error.cause === 'object' && error.cause !== null && 'code' in error.cause
  81. ? String(error.cause.code || '')
  82. : ''
  83. const message = `${error.message} ${code}`.toLowerCase()
  84. return (
  85. message.includes('fetch failed') ||
  86. message.includes('network') ||
  87. message.includes('timeout') ||
  88. message.includes('econnreset') ||
  89. message.includes('eai_again') ||
  90. message.includes('enotfound') ||
  91. message.includes('econnrefused') ||
  92. message.includes('socket')
  93. )
  94. }
  95. function getProgressSnapshot(progressTracker) {
  96. return progressTracker?.getSnapshot() ?? null
  97. }
  98. function logDiagnosticDebug(error) {
  99. if (!debugDiagnostics) {
  100. return
  101. }
  102. const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null
  103. const payload = buildDebugPayload(diagnostic, error)
  104. console.error(chalk.gray('[release:upload] debug diagnostic:'))
  105. console.error(chalk.gray(JSON.stringify(payload, null, 2)))
  106. }
  107. async function readReleaseFiles() {
  108. const entries = await fs.readdir(distDir, { withFileTypes: true })
  109. const files = entries.filter((entry) => entry.isFile())
  110. const mismatchedVersionedFiles = files
  111. .map((entry) => entry.name)
  112. .filter((fileName) => {
  113. const artifactVersion = getArtifactVersion(fileName)
  114. return artifactVersion && artifactVersion !== fullVersion
  115. })
  116. if (mismatchedVersionedFiles.length > 0) {
  117. throw new Error(
  118. `Cannot prepare GitHub Release assets for version ${fullVersion}.\n` +
  119. `Found old build artifacts in dist/: ${mismatchedVersionedFiles.join(', ')}\n` +
  120. `This usually means src/version.json was updated after the last package build, so only latest*.yml still matches.\n` +
  121. `Please rebuild the app for version ${fullVersion}, or clean dist/ before uploading.`,
  122. )
  123. }
  124. // Keep the asset picker strict so repeated uploads remain deterministic across machines.
  125. const selectedFiles = files
  126. .filter((entry) => isReleaseArtifactFile(entry.name, fullVersion))
  127. .map((entry) => ({
  128. name: entry.name,
  129. filePath: path.join(distDir, entry.name),
  130. }))
  131. .sort((a, b) => a.name.localeCompare(b.name))
  132. return Promise.all(
  133. selectedFiles.map(async (file) => ({
  134. ...file,
  135. size: (await fs.stat(file.filePath)).size,
  136. })),
  137. )
  138. }
  139. async function githubRequest(
  140. pathname,
  141. { method = 'GET', body, headers = {}, stage = 'github-request', fileName = null } = {},
  142. ) {
  143. const requestUrl = `https://api.github.com${pathname}`
  144. for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
  145. let response
  146. try {
  147. response = await fetch(requestUrl, {
  148. method,
  149. headers: {
  150. Accept: 'application/vnd.github+json',
  151. Authorization: `Bearer ${token}`,
  152. 'User-Agent': 'SwitchHosts-release-uploader',
  153. 'X-GitHub-Api-Version': '2022-11-28',
  154. ...headers,
  155. },
  156. body,
  157. })
  158. } catch (error) {
  159. const diagnostic = buildDiagnostic({
  160. attempt,
  161. error,
  162. fileName,
  163. maxAttempts: retryAttempts,
  164. method,
  165. retryable: isRetryableFetchError(error),
  166. stage,
  167. target: pathname,
  168. })
  169. if (attempt >= retryAttempts || !isRetryableFetchError(error)) {
  170. throw attachDiagnostic(error, diagnostic)
  171. }
  172. const delayMs = getRetryDelayMs(attempt)
  173. log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs)))
  174. await sleep(delayMs)
  175. continue
  176. }
  177. if (!response.ok) {
  178. const text = await response.text()
  179. const error = new Error(`${method} ${pathname} failed: ${response.status} ${text}`)
  180. const diagnostic = buildDiagnostic({
  181. attempt,
  182. error,
  183. fileName,
  184. httpStatus: response.status,
  185. maxAttempts: retryAttempts,
  186. method,
  187. retryable: isRetryableStatus(response.status),
  188. stage,
  189. target: pathname,
  190. })
  191. if (attempt < retryAttempts && isRetryableStatus(response.status)) {
  192. const delayMs = getRetryDelayMs(attempt)
  193. log(formatRetrySummary(diagnostic, formatRetryDelay(delayMs)))
  194. await sleep(delayMs)
  195. continue
  196. }
  197. throw attachDiagnostic(error, diagnostic)
  198. }
  199. if (response.status === 204) {
  200. return null
  201. }
  202. return response.json()
  203. }
  204. throw new Error(`${method} ${pathname} failed after ${retryAttempts} attempts.`)
  205. }
  206. async function findReleaseByTag() {
  207. let page = 1
  208. const maxPages = 20
  209. while (page <= maxPages) {
  210. // The list API is used here because draft releases are not reliably addressable
  211. // through the single-release-by-tag endpoint.
  212. const releases = await githubRequest(
  213. `/repos/${repository.owner}/${repository.repo}/releases?per_page=100&page=${page}`,
  214. {
  215. stage: 'find-release',
  216. },
  217. )
  218. const found = releases.find((release) => release.tag_name === releaseTag)
  219. if (found) {
  220. return found
  221. }
  222. if (releases.length < 100) {
  223. return null
  224. }
  225. page += 1
  226. }
  227. }
  228. async function createDraftRelease() {
  229. return githubRequest(`/repos/${repository.owner}/${repository.repo}/releases`, {
  230. method: 'POST',
  231. stage: 'create-release',
  232. headers: {
  233. 'Content-Type': 'application/json',
  234. },
  235. body: JSON.stringify({
  236. tag_name: releaseTag,
  237. name: releaseTag,
  238. draft: true,
  239. prerelease: false,
  240. generate_release_notes: false,
  241. }),
  242. })
  243. }
  244. function getUploadUrl(release) {
  245. return release.upload_url.replace(/\{.*$/, '')
  246. }
  247. async function deleteAsset(assetId, assetName) {
  248. await githubRequest(`/repos/${repository.owner}/${repository.repo}/releases/assets/${assetId}`, {
  249. method: 'DELETE',
  250. stage: 'delete-asset',
  251. fileName: assetName,
  252. })
  253. }
  254. async function tryDeleteAssetByName(releaseId, assetName) {
  255. try {
  256. const assets = await githubRequest(
  257. `/repos/${repository.owner}/${repository.repo}/releases/${releaseId}/assets?per_page=100`,
  258. { stage: 'list-assets', fileName: assetName },
  259. )
  260. const match = assets?.find((asset) => asset.name === assetName)
  261. if (match) {
  262. await deleteAsset(match.id, assetName)
  263. }
  264. } catch (_) {
  265. // Best-effort cleanup — don't block the retry if this fails.
  266. }
  267. }
  268. async function uploadAsset(uploadUrl, file, { fileIndex, releaseId, progressTracker } = {}) {
  269. const url = new URL(uploadUrl)
  270. url.searchParams.set('name', file.name)
  271. progressTracker?.startFile(file, fileIndex)
  272. for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
  273. const fileStream = createReadStream(file.filePath)
  274. const trackedStream = fileStream.pipe(
  275. new Transform({
  276. transform(chunk, encoding, callback) {
  277. progressTracker?.advance(chunk.byteLength)
  278. callback(null, chunk)
  279. },
  280. }),
  281. )
  282. let response
  283. try {
  284. response = await fetch(url, {
  285. method: 'POST',
  286. headers: {
  287. Accept: 'application/vnd.github+json',
  288. Authorization: `Bearer ${token}`,
  289. 'Content-Length': String(file.size),
  290. 'Content-Type': 'application/octet-stream',
  291. 'User-Agent': 'SwitchHosts-release-uploader',
  292. 'X-GitHub-Api-Version': '2022-11-28',
  293. },
  294. body: trackedStream,
  295. duplex: 'half',
  296. })
  297. } catch (error) {
  298. fileStream.destroy()
  299. trackedStream.destroy()
  300. const diagnostic = buildDiagnostic({
  301. attempt,
  302. error,
  303. fileIndex,
  304. fileName: file.name,
  305. maxAttempts: retryAttempts,
  306. method: 'POST',
  307. progressSnapshot: getProgressSnapshot(progressTracker),
  308. retryable: isRetryableFetchError(error),
  309. stage: 'upload-asset',
  310. target: url,
  311. })
  312. if (attempt < retryAttempts && isRetryableFetchError(error)) {
  313. const delayMs = getRetryDelayMs(attempt)
  314. await tryDeleteAssetByName(releaseId, file.name)
  315. progressTracker?.resetCurrentFile()
  316. progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`)
  317. await sleep(delayMs)
  318. continue
  319. }
  320. progressTracker?.fail(file.name)
  321. throw attachDiagnostic(error, diagnostic)
  322. }
  323. if (!response.ok) {
  324. fileStream.destroy()
  325. trackedStream.destroy()
  326. const text = await response.text()
  327. const error = new Error(`Upload failed for ${file.name}: ${response.status} ${text}`)
  328. const diagnostic = buildDiagnostic({
  329. attempt,
  330. error,
  331. fileIndex,
  332. fileName: file.name,
  333. httpStatus: response.status,
  334. maxAttempts: retryAttempts,
  335. method: 'POST',
  336. progressSnapshot: getProgressSnapshot(progressTracker),
  337. retryable: isRetryableStatus(response.status),
  338. stage: 'upload-asset',
  339. target: url,
  340. })
  341. if (attempt < retryAttempts && isRetryableStatus(response.status)) {
  342. const delayMs = getRetryDelayMs(attempt)
  343. await tryDeleteAssetByName(releaseId, file.name)
  344. progressTracker?.resetCurrentFile()
  345. progressTracker?.interrupt(`[release:upload] ${formatRetrySummary(diagnostic, formatRetryDelay(delayMs))}`)
  346. await sleep(delayMs)
  347. continue
  348. }
  349. progressTracker?.fail(file.name)
  350. throw attachDiagnostic(error, diagnostic)
  351. }
  352. progressTracker?.completeFile()
  353. return response.json()
  354. }
  355. const exhaustedError = new Error(`Upload failed for ${file.name} after ${retryAttempts} attempts.`)
  356. progressTracker?.fail(file.name)
  357. throw attachDiagnostic(
  358. exhaustedError,
  359. buildDiagnostic({
  360. attempt: retryAttempts,
  361. error: exhaustedError,
  362. fileIndex,
  363. fileName: file.name,
  364. maxAttempts: retryAttempts,
  365. method: 'POST',
  366. progressSnapshot: getProgressSnapshot(progressTracker),
  367. retryable: false,
  368. stage: 'upload-asset',
  369. target: url,
  370. }),
  371. )
  372. }
  373. async function main() {
  374. const files = await readReleaseFiles()
  375. const totalFiles = files.length
  376. const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
  377. if (files.length === 0) {
  378. throw new Error(`No release artifacts found in ${distDir} for version ${fullVersion}.`)
  379. }
  380. log(`repository: ${repository.fullName}`)
  381. log(`release version: ${releaseVersion}`)
  382. log(`release tag: ${releaseTag}`)
  383. log(`artifacts: ${totalFiles} files, ${prettyBytes(totalBytes)}`)
  384. logFileList(files)
  385. if (dryRun) {
  386. log('dry run enabled, skipping GitHub API calls.')
  387. return
  388. }
  389. if (!token) {
  390. throw new Error('GH_TOKEN is required unless DRY_RUN=1 is set.')
  391. }
  392. let release = await findReleaseByTag()
  393. if (!release) {
  394. log(`release ${releaseTag} not found, creating draft release...`)
  395. release = await createDraftRelease()
  396. } else {
  397. log(`using existing release ${releaseTag} (draft=${release.draft}, prerelease=${release.prerelease})`)
  398. }
  399. const uploadUrl = getUploadUrl(release)
  400. const existingAssets = new Map(release.assets.map((asset) => [asset.name, asset]))
  401. const progressTracker = createUploadProgressTracker({
  402. totalBytes,
  403. totalFiles,
  404. log,
  405. })
  406. const logUploadStatus = (message) => progressTracker.interrupt(`[release:upload] ${message}`)
  407. for (const [ index, file ] of files.entries()) {
  408. const existingAsset = existingAssets.get(file.name)
  409. if (existingAsset) {
  410. // Replace same-name assets so different machines can safely append
  411. // or refresh artifacts for the same draft release.
  412. logUploadStatus(`replacing existing asset ${file.name}`)
  413. await deleteAsset(existingAsset.id, file.name)
  414. } else {
  415. logUploadStatus(`uploading new asset ${file.name}`)
  416. }
  417. await uploadAsset(uploadUrl, file, {
  418. fileIndex: index + 1,
  419. releaseId: release.id,
  420. progressTracker,
  421. })
  422. }
  423. progressTracker.finish()
  424. log(`done: ${release.html_url}`)
  425. }
  426. try {
  427. await main()
  428. } catch (error) {
  429. const diagnostic = error instanceof Error && 'diagnostic' in error ? error.diagnostic : null
  430. const message = diagnostic ? formatDiagnosticSummary(diagnostic) : error instanceof Error ? error.message : String(error)
  431. console.error(chalk.red(`[release:upload] ${message}`))
  432. logDiagnosticDebug(error)
  433. process.exit(1)
  434. }