update-artifact.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. type Artifact = {
  2. channel: string
  3. name: string
  4. distribution: string
  5. version: string
  6. metadata: Record<string, unknown>
  7. }
  8. type DesktopFile = {
  9. url: string
  10. sha512: string
  11. size: number
  12. blockMapSize?: number
  13. }
  14. export namespace UpdateArtifact {
  15. export async function publish(artifact: Artifact) {
  16. if (process.env.GITHUB_ACTIONS !== "true") {
  17. console.log("skipped update artifact publication outside GitHub Actions")
  18. return
  19. }
  20. const requestURL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL
  21. const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
  22. if (!requestURL || !requestToken) throw new Error("GitHub Actions OIDC is unavailable")
  23. const url = new URL(requestURL)
  24. url.searchParams.set("audience", "https://update.opencode.ai")
  25. const tokenResponse = await fetch(url, { headers: { Authorization: `Bearer ${requestToken}` } })
  26. if (!tokenResponse.ok) throw new Error(`Failed to request GitHub OIDC token: ${tokenResponse.status}`)
  27. const token: unknown = await tokenResponse.json()
  28. if (!isRecord(token) || typeof token.value !== "string")
  29. throw new Error("GitHub OIDC response did not include a token")
  30. const response = await fetch("https://update.opencode.ai/api/publish", {
  31. method: "POST",
  32. headers: {
  33. Authorization: `Bearer ${token.value}`,
  34. "Content-Type": "application/json",
  35. },
  36. body: JSON.stringify(artifact),
  37. })
  38. if (response.ok) return
  39. throw new Error(`Failed to publish update artifact: ${response.status} ${await response.text()}`)
  40. }
  41. export async function desktopMetadata(version: string, repo: string) {
  42. const directory = process.env.RUNNER_TEMP ?? "/tmp"
  43. const entries = await Promise.all(
  44. [
  45. ["desktop.yml", "latest.yml"],
  46. ["desktop-mac.yml", "latest-mac.yml"],
  47. ["desktop-linux.yml", "latest-linux.yml"],
  48. ["desktop-linux-arm64.yml", "latest-linux-arm64.yml"],
  49. ].map(async ([name, source]) => {
  50. const file = Bun.file(`${directory}/${source}`)
  51. if (!(await file.exists())) return
  52. return [name, parseDesktop(await file.text(), version, repo)] as const
  53. }),
  54. )
  55. const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined))
  56. if (!Object.keys(manifests).length) throw new Error("No desktop update metadata found")
  57. return { manifests }
  58. }
  59. }
  60. function parseDesktop(content: string, version: string, repo: string) {
  61. const lines = content.split("\n")
  62. const found = lines
  63. .find((line) => line.startsWith("version:"))
  64. ?.slice("version:".length)
  65. .trim()
  66. if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`)
  67. const releaseDate = lines
  68. .find((line) => line.startsWith("releaseDate:"))
  69. ?.slice("releaseDate:".length)
  70. .trim()
  71. .replace(/^['"]|['"]$/g, "")
  72. if (!releaseDate) throw new Error("Desktop metadata did not include a release date")
  73. const files: DesktopFile[] = []
  74. lines.forEach((line) => {
  75. const value = line.trim()
  76. if (value.startsWith("- url:")) {
  77. const name = value.slice("- url:".length).trim()
  78. files.push({
  79. url: name.startsWith("http")
  80. ? name
  81. : `https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(name)}`,
  82. sha512: "",
  83. size: 0,
  84. })
  85. return
  86. }
  87. const current = files.at(-1)
  88. if (!current) return
  89. if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim()
  90. if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim())
  91. if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim())
  92. })
  93. if (!files.length || files.some((file) => !file.sha512 || !file.size)) {
  94. throw new Error("Desktop metadata contained an incomplete file")
  95. }
  96. return { files, releaseDate }
  97. }
  98. function isRecord(input: unknown): input is Record<string, unknown> {
  99. return typeof input === "object" && input !== null && !Array.isArray(input)
  100. }