update-artifact.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  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") throw new Error("GitHub OIDC response did not include a token")
  29. const response = await fetch("https://update.opencode.ai/api/publish", {
  30. method: "POST",
  31. headers: {
  32. Authorization: `Bearer ${token.value}`,
  33. "Content-Type": "application/json",
  34. },
  35. body: JSON.stringify(artifact),
  36. })
  37. if (response.ok) return
  38. throw new Error(`Failed to publish update artifact: ${response.status} ${await response.text()}`)
  39. }
  40. export async function desktopMetadata(version: string, repo: string) {
  41. const directory = process.env.RUNNER_TEMP ?? "/tmp"
  42. const entries = await Promise.all(
  43. [
  44. ["desktop.yml", "latest.yml"],
  45. ["desktop-mac.yml", "latest-mac.yml"],
  46. ["desktop-linux.yml", "latest-linux.yml"],
  47. ["desktop-linux-arm64.yml", "latest-linux-arm64.yml"],
  48. ].map(async ([name, source]) => {
  49. const file = Bun.file(`${directory}/${source}`)
  50. if (!(await file.exists())) return
  51. return [name, parseDesktop(await file.text(), version, repo)] as const
  52. }),
  53. )
  54. const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined))
  55. if (!Object.keys(manifests).length) throw new Error("No desktop update metadata found")
  56. return { manifests }
  57. }
  58. }
  59. function parseDesktop(content: string, version: string, repo: string) {
  60. const lines = content.split("\n")
  61. const found = lines.find((line) => line.startsWith("version:"))?.slice("version:".length).trim()
  62. if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`)
  63. const releaseDate = lines
  64. .find((line) => line.startsWith("releaseDate:"))
  65. ?.slice("releaseDate:".length)
  66. .trim()
  67. .replace(/^['"]|['"]$/g, "")
  68. if (!releaseDate) throw new Error("Desktop metadata did not include a release date")
  69. const files: DesktopFile[] = []
  70. lines.forEach((line) => {
  71. const value = line.trim()
  72. if (value.startsWith("- url:")) {
  73. const name = value.slice("- url:".length).trim()
  74. files.push({
  75. url: name.startsWith("http")
  76. ? name
  77. : `https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(name)}`,
  78. sha512: "",
  79. size: 0,
  80. })
  81. return
  82. }
  83. const current = files.at(-1)
  84. if (!current) return
  85. if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim()
  86. if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim())
  87. if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim())
  88. })
  89. if (!files.length || files.some((file) => !file.sha512 || !file.size)) {
  90. throw new Error("Desktop metadata contained an incomplete file")
  91. }
  92. return { files, releaseDate }
  93. }
  94. function isRecord(input: unknown): input is Record<string, unknown> {
  95. return typeof input === "object" && input !== null && !Array.isArray(input)
  96. }