Files
hanzo.ai/scripts/sync-releases.mjs

212 lines
8.6 KiB
JavaScript

/**
* Resolve every artifact the site offers against the releases that actually exist.
*
* Two pages read what this writes — `/download` through `data/downloads.ts` and
* `/install` through `data/app-catalog.ts` — so a build either page names is
* resolved once, here, and the two cannot drift into disagreeing about a URL.
*
* The page names a PLATFORM and an asset PATTERN; this writes the URL. That
* split exists because half our repos version the filename — the extension
* publishes `hanzo-ai-desktop-macos-v1.9.31.dmg`, so the obvious
* `releases/latest/download/<name>` link works until 1.9.32 ships and then
* 404s for everyone. A pattern cannot go stale that way.
*
* It also catches the other direction, which is how this started: hanzoai/desktop
* 1.1.48 published a macOS `.sig` and no macOS binary, so the tile a reader
* would have clicked answered 404 while the release looked healthy. An asset
* that is not in the payload is not written, and a platform with no asset does
* not render — the page cannot advertise a build that does not exist.
*
* Three rules, the same three every sync in this estate keeps:
* - never fail the build on an unreachable API — the committed snapshot is
* the designed fallback, and a page must not need github.com to deploy;
* - never regress — a payload resolving FEWER assets than the snapshot is far
* more likely to be a degraded response than a product being retired, so it
* is refused and what it would have dropped is printed;
* - never go quiet — every run prints what resolved and what did not.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'releases.json')
/**
* What each surface publishes, as `platform -> pattern`.
*
* The pattern matches the asset NAME. Checksums and signatures are never
* candidates: a `.sig` is not a download, and treating one as a build is the
* exact defect above.
*/
const SURFACES = {
cli: {
repo: 'hanzoai/cli',
assets: {
'macOS (Apple Silicon)': /^hanzo-darwin-arm64\.tar\.gz$/,
'macOS (Intel)': /^hanzo-darwin-amd64\.tar\.gz$/,
Windows: /^hanzo-windows-amd64\.tar\.gz$/,
'Linux (x86_64)': /^hanzo-linux-amd64\.tar\.gz$/,
'Linux (arm64)': /^hanzo-linux-arm64\.tar\.gz$/,
},
},
desktop: {
repo: 'hanzoai/desktop',
assets: {
macOS: /^Hanzo[.-]Desktop.*\.dmg$/,
'Linux (AppImage)': /^Hanzo[.-]Desktop.*amd64.*\.AppImage$/,
'Linux (deb)': /^Hanzo[.-]Desktop.*amd64.*\.deb$/,
'Linux (arm64)': /^Hanzo[.-]Desktop.*aarch64.*\.AppImage$/,
},
},
browser: {
repo: 'hanzoai/extension',
assets: {
Chrome: /^hanzo-ai-chrome-v[\d.]+\.zip$/,
Firefox: /^hanzo-ai-firefox-v[\d.]+\.zip$/,
Safari: /^hanzo-ai-safari-v[\d.]+\.zip$/,
Edge: /^hanzo-ai-edge-v[\d.]+\.zip$/,
},
},
editor: {
repo: 'hanzoai/extension',
assets: {
'VS Code': /^hanzo-ai-vscode-v[\d.]+\.vsix$/,
Cursor: /^hanzo-ai-cursor-v[\d.]+\.vsix$/,
Windsurf: /^hanzo-ai-windsurf-v[\d.]+\.vsix$/,
Antigravity: /^hanzo-ai-antigravity-v[\d.]+\.vsix$/,
JetBrains: /^hanzo-ai-jetbrains-v[\d.]+\.zip$/,
},
},
// Apps that Hanzo installs INTO, the same way browser/editor/desktop are.
// `office-localhost` is a sideload build for development and is not one of
// these — the version anchor in each pattern is what keeps it out.
host: {
repo: 'hanzoai/extension',
assets: {
Office: /^hanzo-ai-office-v[\d.]+\.zip$/,
Outlook: /^hanzo-ai-outlook-v[\d.]+\.zip$/,
Figma: /^hanzo-ai-figma-v[\d.]+\.zip$/,
Sketch: /^hanzo-ai-sketch-v[\d.]+\.zip$/,
Teams: /^hanzo-ai-teams-v[\d.]+\.zip$/,
Zendesk: /^hanzo-ai-zendesk-v[\d.]+\.zip$/,
'Claude Desktop': /^hanzo-ai-claude-v[\d.]+\.dxt$/,
JupyterLab: /^hanzo_jupyter-[\d.]+-py3-none-any\.whl$/,
},
},
}
const snapshot = () => {
try {
return JSON.parse(readFileSync(OUT, 'utf8'))
} catch {
return null
}
}
/**
* The latest release of `repo`, asked for the way a READER asks for it.
*
* No credential, ever, and that is the whole point of this function. It used to
* send `GITHUB_TOKEN` when CI had one, which asks a question no visitor can
* ask: hanzoai/extension is a PRIVATE repo, so an authenticated build resolved
* all 23 of its assets, wrote them to the snapshot, and every reader who
* clicked one got 404. Measured 2026-08-27 against the live page: the six
* desktop, browser and editor links on hanzo.app/download answered 404
* anonymously and 200 with a token, and hanzoai/cli — the one PUBLIC repo —
* was the only surface on the page that worked.
*
* A build that can see more than a reader can fetch is the defect, not the
* feature. Asking anonymously makes the resolver and the reader the same
* client, so the page cannot offer a download the reader cannot take.
*
* `null` is a real answer, not a failure: 404 means this repo publishes nothing
* to the public — private, renamed, or gone — and in every one of those cases
* the reader gets 404 too, so the right page is one with no card. Anything else
* (rate limit, 5xx, DNS) is an outage and throws, which keeps the snapshot.
*/
async function release(repo) {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { accept: 'application/vnd.github+json' },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`${repo}: ${res.status}`)
return res.json()
}
async function main() {
const held = snapshot()
let next
let served = new Set()
try {
const repos = [...new Set(Object.values(SURFACES).map((s) => s.repo))]
const payloads = Object.fromEntries(
await Promise.all(repos.map(async (r) => [r, await release(r)])),
)
// The repos that served a release to an anonymous caller. A repo missing
// from this set published nothing a reader can take, and its surfaces
// resolve to no platforms rather than to links that answer 404.
served = new Set(repos.filter((r) => payloads[r]))
next = {}
for (const [id, { repo, assets }] of Object.entries(SURFACES)) {
const payload = payloads[repo]
if (!payload) {
next[id] = { repo, platforms: {} }
console.log(` ${id.padEnd(8)} ${'—'.padEnd(10)} 0/${Object.keys(assets).length} NOT PUBLIC: ${repo} serves no release to a reader`)
continue
}
const published = payload.assets ?? []
const found = {}
for (const [platform, pattern] of Object.entries(assets)) {
const hit = published.find((a) => pattern.test(a.name))
if (hit) found[platform] = { url: hit.browser_download_url, size: hit.size }
}
next[id] = { repo, version: payload.tag_name, platforms: found }
const missing = Object.keys(assets).filter((p) => !found[p])
console.log(
` ${id.padEnd(8)} ${payload.tag_name.padEnd(10)} ${Object.keys(found).length}/${Object.keys(assets).length}` +
(missing.length ? ` NOT PUBLISHED: ${missing.join(', ')}` : ''),
)
}
} catch (err) {
// An unreachable API is not a build failure. The snapshot is the fallback.
console.log(` releases unreachable (${err.message}) — keeping the committed snapshot`)
if (!held) throw new Error('no snapshot on disk and no API: cannot resolve downloads')
return
}
// Never regress: a shrinking payload is a degraded response, not a retirement.
//
// Counted across the repos that SERVED this run, and only those. A repo that
// answered 404 has nothing to compare, and its entries in the snapshot are
// links a reader cannot take — so counting them would let dead URLs outvote
// the truth and hold themselves on the page permanently. That is not a
// hypothetical: this guard was what kept six unreachable extension links
// published while the resolver believed it had verified all of them.
if (held) {
const count = (s) =>
Object.entries(s).reduce(
(n, [id, v]) => (served.has(SURFACES[id]?.repo) ? n + Object.keys(v.platforms).length : n),
0,
)
if (count(next) < count(held)) {
console.log(` REFUSED: resolves ${count(next)} assets, snapshot holds ${count(held)}`)
for (const id of Object.keys(held)) {
const gone = Object.keys(held[id].platforms).filter((p) => !next[id]?.platforms[p])
if (gone.length) console.log(` ${id}: would drop ${gone.join(', ')}`)
}
return
}
}
writeFileSync(OUT, `${JSON.stringify(next, null, 2)}\n`)
console.log(` wrote ${OUT}`)
}
main().catch((err) => {
console.error(err.message)
process.exit(1)
})