From 4aacbc72463452a2ebdddaaac5974daabbbd893c Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 20 Feb 2026 13:21:04 -0800 Subject: [PATCH] fix(release): publish deterministic updater platforms metadata --- .github/workflows/release-macos-aarch64.yml | 37 +++- scripts/release/generate-latest-json.mjs | 200 ++++++++++++++++++++ 2 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 scripts/release/generate-latest-json.mjs diff --git a/.github/workflows/release-macos-aarch64.yml b/.github/workflows/release-macos-aarch64.yml index 85f7a5391..3db72808a 100644 --- a/.github/workflows/release-macos-aarch64.yml +++ b/.github/workflows/release-macos-aarch64.yml @@ -524,7 +524,7 @@ jobs: tauriScript: pnpm exec tauri -vvv args: ${{ matrix.args }} retryAttempts: 3 - uploadUpdaterJson: true + uploadUpdaterJson: false updaterJsonPreferNsis: true releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext] @@ -553,7 +553,7 @@ jobs: tauriScript: pnpm exec tauri -vvv args: ${{ matrix.args }} retryAttempts: 3 - uploadUpdaterJson: true + uploadUpdaterJson: false updaterJsonPreferNsis: true releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext] @@ -600,6 +600,39 @@ jobs: echo "Found bundled versions.json at $manifest_path" + publish-updater-json: + name: Publish consolidated latest.json + needs: [resolve-release, verify-release, publish-tauri] + if: needs.resolve-release.outputs.build_tauri == 'true' + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Generate latest.json from release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node scripts/release/generate-latest-json.mjs \ + --tag "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --output "$RUNNER_TEMP/latest.json" + + - name: Upload latest.json + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/latest.json#latest.json" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + release-orchestrator-sidecars: name: Build + Upload openwork-orchestrator Sidecars needs: [resolve-release, verify-release] diff --git a/scripts/release/generate-latest-json.mjs b/scripts/release/generate-latest-json.mjs new file mode 100644 index 000000000..773984a57 --- /dev/null +++ b/scripts/release/generate-latest-json.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +const ARCH_ALIASES = new Map([ + ["x64", "x86_64"], + ["amd64", "x86_64"], + ["arm64", "aarch64"], +]); + +function normalizeArch(arch) { + const key = String(arch || "").trim().toLowerCase(); + return ARCH_ALIASES.get(key) || key; +} + +function parseArgs(argv) { + const options = { + tag: process.env.RELEASE_TAG || "", + repo: process.env.GITHUB_REPOSITORY || "different-ai/openwork", + output: "latest.json", + }; + + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--tag") { + options.tag = argv[i + 1] || ""; + i += 1; + continue; + } + if (arg === "--repo") { + options.repo = argv[i + 1] || options.repo; + i += 1; + continue; + } + if (arg === "--output") { + options.output = argv[i + 1] || options.output; + i += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!options.tag) { + throw new Error("Missing release tag. Pass --tag vX.Y.Z or set RELEASE_TAG."); + } + + return options; +} + +function updaterPlatformKeys(assetName) { + if (!assetName.startsWith("openwork-desktop-")) return []; + + const stem = assetName.slice("openwork-desktop-".length); + + if (stem.endsWith(".app.tar.gz")) { + const match = stem.match(/^([^-]+)-([^.]+)\.app\.tar\.gz$/); + if (!match) return []; + const platform = match[1]; + const arch = normalizeArch(match[2]); + const base = `${platform}-${arch}`; + if (platform === "darwin") { + return [base, `${base}-app`]; + } + return [base]; + } + + if (stem.endsWith(".msi")) { + const match = stem.match(/^([^-]+)-([^.]+)\.msi$/); + if (!match) return []; + const platform = match[1]; + const arch = normalizeArch(match[2]); + const base = `${platform}-${arch}`; + return [base, `${base}-msi`]; + } + + if (stem.endsWith(".deb")) { + const match = stem.match(/^([^-]+)-([^.]+)\.deb$/); + if (!match) return []; + const platform = match[1]; + const arch = normalizeArch(match[2]); + const base = `${platform}-${arch}`; + return [base, `${base}-deb`]; + } + + if (stem.endsWith(".rpm")) { + const match = stem.match(/^([^-]+)-([^.]+)\.rpm$/); + if (!match) return []; + const platform = match[1]; + const arch = normalizeArch(match[2]); + return [`${platform}-${arch}-rpm`]; + } + + if (stem.endsWith(".AppImage")) { + const match = stem.match(/^([^-]+)-([^.]+)\.AppImage$/); + if (!match) return []; + const platform = match[1]; + const arch = normalizeArch(match[2]); + return [`${platform}-${arch}`]; + } + + return []; +} + +function authHeaders() { + const headers = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "openwork-release-latest-json", + }; + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function fetchJson(url) { + const response = await fetch(url, { headers: authHeaders() }); + if (!response.ok) { + throw new Error(`GitHub API request failed (${response.status}): ${url}`); + } + return response.json(); +} + +async function fetchText(url) { + const response = await fetch(url, { headers: authHeaders() }); + if (!response.ok) { + throw new Error(`Failed to download signature (${response.status}): ${url}`); + } + return response.text(); +} + +function sortObjectEntries(input) { + const sorted = {}; + for (const key of Object.keys(input).sort()) { + sorted[key] = input[key]; + } + return sorted; +} + +async function main() { + const { tag, repo, output } = parseArgs(process.argv); + const releaseUrl = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`; + const release = await fetchJson(releaseUrl); + + const assets = Array.isArray(release.assets) ? release.assets : []; + const assetsByName = new Map(); + for (const asset of assets) { + if (asset && typeof asset.name === "string") { + assetsByName.set(asset.name, asset); + } + } + + const platforms = {}; + + for (const asset of assets) { + if (!asset || typeof asset.name !== "string" || !asset.name.endsWith(".sig")) continue; + + const targetName = asset.name.slice(0, -4); + const targetAsset = assetsByName.get(targetName); + if (!targetAsset || typeof targetAsset.browser_download_url !== "string") continue; + + const keys = updaterPlatformKeys(targetName); + if (!keys.length) continue; + + const signature = (await fetchText(asset.browser_download_url)).trim(); + if (!signature) continue; + + for (const key of keys) { + platforms[key] = { + signature, + url: targetAsset.browser_download_url, + }; + } + } + + if (!Object.keys(platforms).length) { + throw new Error(`No updater platforms were resolved for ${repo}@${tag}.`); + } + + const version = String(release.tag_name || tag).replace(/^v/, ""); + const latest = { + version, + notes: + typeof release.body === "string" && release.body.trim() + ? release.body + : "See the assets to download this version and install.", + pub_date: release.published_at || new Date().toISOString(), + platforms: sortObjectEntries(platforms), + }; + + const fs = await import("node:fs/promises"); + await fs.writeFile(output, `${JSON.stringify(latest, null, 2)}\n`, "utf8"); + + console.log(`Wrote ${output} with ${Object.keys(latest.platforms).length} updater platforms.`); +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error(message); + process.exit(1); +});