finalize-latest-json.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. #!/usr/bin/env bun
  2. import { $ } from "bun"
  3. import path from "node:path"
  4. import { parseArgs } from "node:util"
  5. const { values } = parseArgs({
  6. args: Bun.argv.slice(2),
  7. options: {
  8. "dry-run": { type: "boolean", default: false },
  9. },
  10. })
  11. const dryRun = values["dry-run"]
  12. const repo = process.env.GH_REPO
  13. if (!repo) throw new Error("GH_REPO is required")
  14. const releaseId = process.env.OPENCODE_RELEASE
  15. if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
  16. const version = process.env.OPENCODE_VERSION
  17. if (!version) throw new Error("OPENCODE_VERSION is required")
  18. const dir = process.env.LATEST_YML_DIR
  19. if (!dir) throw new Error("LATEST_YML_DIR is required")
  20. const root = dir
  21. const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
  22. if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
  23. const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
  24. headers: {
  25. Authorization: `token ${token}`,
  26. Accept: "application/vnd.github+json",
  27. },
  28. })
  29. if (!rel.ok) {
  30. throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`)
  31. }
  32. type Asset = {
  33. name: string
  34. url: string
  35. }
  36. type Release = {
  37. assets?: Asset[]
  38. }
  39. const assets = ((await rel.json()) as Release).assets ?? []
  40. const amap = new Map(assets.map((item) => [item.name, item]))
  41. type Item = {
  42. url: string
  43. }
  44. type Yml = {
  45. version: string
  46. files: Item[]
  47. }
  48. function parse(text: string): Yml {
  49. const lines = text.split("\n")
  50. let version = ""
  51. const files: Item[] = []
  52. let url = ""
  53. const flush = () => {
  54. if (!url) return
  55. files.push({ url })
  56. url = ""
  57. }
  58. for (const line of lines) {
  59. const trim = line.trim()
  60. if (line.startsWith("version:")) {
  61. version = line.slice("version:".length).trim()
  62. continue
  63. }
  64. if (trim.startsWith("- url:")) {
  65. flush()
  66. url = trim.slice("- url:".length).trim()
  67. continue
  68. }
  69. const indented = line.startsWith(" ") || line.startsWith("\t")
  70. if (!indented) flush()
  71. }
  72. flush()
  73. return { version, files }
  74. }
  75. async function read(sub: string, file: string) {
  76. const item = Bun.file(path.join(root, sub, file))
  77. if (!(await item.exists())) return undefined
  78. return parse(await item.text())
  79. }
  80. function pick(list: Item[], exts: string[]) {
  81. for (const ext of exts) {
  82. const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext))
  83. if (found) return found.url
  84. }
  85. }
  86. function link(raw: string) {
  87. if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
  88. return `https://github.com/${repo}/releases/download/v${version}/${raw}`
  89. }
  90. async function sign(url: string, key: string) {
  91. const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key)
  92. const asset = amap.get(name)
  93. const res = await fetch(asset?.url ?? url, {
  94. headers: {
  95. Authorization: `token ${token}`,
  96. ...(asset ? { Accept: "application/octet-stream" } : {}),
  97. },
  98. })
  99. if (!res.ok) {
  100. throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`)
  101. }
  102. const tmp = process.env.RUNNER_TEMP ?? "/tmp"
  103. const file = path.join(tmp, name)
  104. await Bun.write(file, await res.arrayBuffer())
  105. await $`bunx @tauri-apps/cli signer sign ${file}`
  106. const sigFile = Bun.file(`${file}.sig`)
  107. if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`)
  108. return (await sigFile.text()).trim()
  109. }
  110. const add = async (data: Record<string, { url: string; signature: string }>, key: string, raw: string | undefined) => {
  111. if (!raw) return
  112. if (data[key]) return
  113. const url = link(raw)
  114. data[key] = { url, signature: await sign(url, key) }
  115. }
  116. const alias = (data: Record<string, { url: string; signature: string }>, key: string, src: string) => {
  117. if (data[key]) return
  118. if (!data[src]) return
  119. data[key] = data[src]
  120. }
  121. const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
  122. const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
  123. const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
  124. const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
  125. const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
  126. const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
  127. const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version
  128. if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`)
  129. const out: Record<string, { url: string; signature: string }> = {}
  130. const winxexe = pick(winx?.files ?? [], [".exe"])
  131. const winaexe = pick(wina?.files ?? [], [".exe"])
  132. const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz"
  133. const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz"
  134. const linxDeb = pick(linx?.files ?? [], [".deb"])
  135. const linxRpm = pick(linx?.files ?? [], [".rpm"])
  136. const linxAppImage = pick(linx?.files ?? [], [".appimage"])
  137. const linaDeb = pick(lina?.files ?? [], [".deb"])
  138. const linaRpm = pick(lina?.files ?? [], [".rpm"])
  139. const linaAppImage = pick(lina?.files ?? [], [".appimage"])
  140. await add(out, "windows-x86_64-nsis", winxexe)
  141. await add(out, "windows-aarch64-nsis", winaexe)
  142. await add(out, "darwin-x86_64-app", macxTarGz)
  143. await add(out, "darwin-aarch64-app", macaTarGz)
  144. await add(out, "linux-x86_64-deb", linxDeb)
  145. await add(out, "linux-x86_64-rpm", linxRpm)
  146. await add(out, "linux-x86_64-appimage", linxAppImage)
  147. await add(out, "linux-aarch64-deb", linaDeb)
  148. await add(out, "linux-aarch64-rpm", linaRpm)
  149. await add(out, "linux-aarch64-appimage", linaAppImage)
  150. alias(out, "windows-x86_64", "windows-x86_64-nsis")
  151. alias(out, "windows-aarch64", "windows-aarch64-nsis")
  152. alias(out, "darwin-x86_64", "darwin-x86_64-app")
  153. alias(out, "darwin-aarch64", "darwin-aarch64-app")
  154. alias(out, "linux-x86_64", "linux-x86_64-deb")
  155. alias(out, "linux-aarch64", "linux-aarch64-deb")
  156. const platforms = Object.fromEntries(
  157. Object.keys(out)
  158. .sort()
  159. .map((key) => [key, out[key]]),
  160. )
  161. if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts")
  162. const data = {
  163. version,
  164. notes: "",
  165. pub_date: new Date().toISOString(),
  166. platforms,
  167. }
  168. const tmp = process.env.RUNNER_TEMP ?? "/tmp"
  169. const file = path.join(tmp, "latest.json")
  170. await Bun.write(file, JSON.stringify(data, null, 2))
  171. const tag = `v${version}`
  172. if (dryRun) {
  173. console.log(`dry-run: wrote latest.json for ${tag} to ${file}`)
  174. process.exit(0)
  175. }
  176. await $`gh release upload ${tag} ${file} --clobber --repo ${repo}`
  177. console.log(`finalized latest.json for ${tag}`)