From c8859b4d3f6ea87901275f40068ef97e31419f76 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:17:43 +0800 Subject: [PATCH] ci(release): mirror published releases to CNB (#1683) After a release is published, copy its assets to cnb.cool/l0ng-ai/openidea and upload a latest.json whose bundle URLs point there, last, so the mirror never names a bundle it lacks. Keeps the newest 10 releases. The CNB mirror workflow can also be run by hand for an existing tag, and is skipped without CNB_TOKEN. --- .github/workflows/cnb-mirror.yml | 52 ++++++++++ .github/workflows/release.yml | 7 ++ scripts/cnb-mirror.test.ts | 54 +++++++++++ scripts/cnb-mirror.ts | 159 +++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 .github/workflows/cnb-mirror.yml create mode 100644 scripts/cnb-mirror.test.ts create mode 100644 scripts/cnb-mirror.ts diff --git a/.github/workflows/cnb-mirror.yml b/.github/workflows/cnb-mirror.yml new file mode 100644 index 000000000..2021f4f8d --- /dev/null +++ b/.github/workflows/cnb-mirror.yml @@ -0,0 +1,52 @@ +name: CNB mirror + +# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source +# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror +# an existing tag again. Skipped without the CNB_TOKEN secret. +on: + workflow_call: + inputs: + tag: { type: string, required: true } + workflow_dispatch: + inputs: + tag: + description: "Published release to mirror (e.g. v0.1.38)" + required: true + +permissions: + contents: read + +concurrency: + group: cnb-mirror-${{ inputs.tag }} + +jobs: + mirror: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + TAG: ${{ inputs.tag }} + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Check token + id: token + run: | + if [ -z "$CNB_TOKEN" ]; then + echo "::notice::CNB_TOKEN unset: not mirroring $TAG" + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v5 + if: steps.token.outputs.skip != 'true' + with: { sparse-checkout: scripts } + - uses: actions/setup-node@v5 + if: steps.token.outputs.skip != 'true' + with: { node-version: 22 } + - name: Download release + if: steps.token.outputs.skip != 'true' + run: | + gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets + gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json + du -sh assets + - name: Upload to CNB + if: steps.token.outputs.skip != 'true' + run: node scripts/cnb-mirror.ts "$TAG" assets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed6644138..f2d189465 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -355,3 +355,10 @@ jobs: await github.rest.repos.updateRelease({ ...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false, }); + + # The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is. + cnb-mirror: + needs: publish + uses: ./.github/workflows/cnb-mirror.yml + with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" } + secrets: inherit diff --git a/scripts/cnb-mirror.test.ts b/scripts/cnb-mirror.test.ts new file mode 100644 index 000000000..ee59cc8c2 --- /dev/null +++ b/scripts/cnb-mirror.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { KEEP, mirrorManifest, releasesToPrune } from "./cnb-mirror.ts"; + +describe("mirrorManifest", () => { + const gh = "https://github.com/l0ng-ai/openidea/releases/download/v0.1.38"; + const manifest = { + version: "0.1.38", + notes: "", + pub_date: "2026-11-06T00:00:00Z", + platforms: { + "darwin-aarch64": { signature: "sig-mac", url: `${gh}/OpenIDEA_aarch64.app.tar.gz` }, + "windows-x86_64-nsis": { signature: "sig-win", url: `${gh}/OpenIDEA_0.1.38_x64-setup.exe` }, + }, + }; + + it("points every bundle at the CNB release and keeps the signatures", () => { + const cnb = "https://cnb.cool/l0ng-ai/openidea/-/releases/download/v0.1.38"; + expect(mirrorManifest(manifest, "v0.1.38")).toEqual({ + ...manifest, + platforms: { + "darwin-aarch64": { signature: "sig-mac", url: `${cnb}/OpenIDEA_aarch64.app.tar.gz` }, + "windows-x86_64-nsis": { signature: "sig-win", url: `${cnb}/OpenIDEA_0.1.38_x64-setup.exe` }, + }, + }); + }); + + it("refuses a manifest of another release", () => { + expect(() => mirrorManifest(manifest, "v0.1.39")).toThrow(/darwin-aarch64/); + }); +}); + +describe("releasesToPrune", () => { + const release = (n: number) => ({ + id: String(n), + tag_name: `v0.1.${n}`, + created_at: `2026-11-${String(n).padStart(2, "0")}T00:00:00Z`, + }); + + it("keeps the newest releases and removes the rest", () => { + const releases = Array.from({ length: KEEP + 3 }, (_, i) => release(i + 1)).reverse(); + expect(releasesToPrune(releases, `v0.1.${KEEP + 3}`).map((r) => r.tag_name)).toEqual([ + "v0.1.3", + "v0.1.2", + "v0.1.1", + ]); + }); + + it("removes nothing within the limit, and never the release being mirrored", () => { + expect(releasesToPrune([release(1), release(2)], "v0.1.2")).toEqual([]); + // A rerun for an old tag: it is the oldest, but it was just uploaded. + expect(releasesToPrune([release(1), release(2), release(3)], "v0.1.1", 2)).toEqual([]); + }); +}); diff --git a/scripts/cnb-mirror.ts b/scripts/cnb-mirror.ts new file mode 100644 index 000000000..e86031f30 --- /dev/null +++ b/scripts/cnb-mirror.ts @@ -0,0 +1,159 @@ +/** + * Mirrors a published release to CNB (cnb.cool/l0ng-ai/openidea), where the updater's + * CNB source reads `releases/latest/download/latest.json`: + * `node scripts/cnb-mirror.ts ` with `CNB_TOKEN` set (scopes `repo-code:r`, + * `repo-release:rw`). `` holds the release's assets (`gh release download`) and + * `release.json` (`gh release view --json body,isPrerelease`). Every asset is uploaded, + * `latest.json` last and pointing at CNB, so the mirror's manifest never names a bundle it + * lacks. Only the newest `KEEP` releases stay on CNB. Safe to rerun: uploads overwrite. + */ +import { openAsBlob, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CNB_REPO = "l0ng-ai/openidea"; +const GITHUB_REPO = "l0ng-ai/openidea"; +const API = `https://api.cnb.cool/${CNB_REPO}`; +export const KEEP = 10; +const PARALLEL = 4; +const MANIFEST = "latest.json"; +const RELEASE_INFO = "release.json"; + +interface Manifest { + version: string; + notes?: string; + pub_date?: string; + platforms: Record; +} + +interface CnbRelease { + id: string; + tag_name: string; + created_at: string; +} + +/** `manifest` with each bundle URL moved from the GitHub release to the CNB one. */ +export function mirrorManifest(manifest: Manifest, tag: string): Manifest { + const from = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/`; + const to = `https://cnb.cool/${CNB_REPO}/-/releases/download/${tag}/`; + const platforms: Manifest["platforms"] = {}; + for (const [key, entry] of Object.entries(manifest.platforms)) { + if (!entry.url.startsWith(from)) throw new Error(`${key}: ${entry.url} is not an asset of ${tag}`); + platforms[key] = { ...entry, url: to + entry.url.slice(from.length) }; + } + return { ...manifest, platforms }; +} + +/** Releases beyond the newest `keep` (by creation time), never `current`. */ +export function releasesToPrune(releases: CnbRelease[], current: string, keep = KEEP): CnbRelease[] { + const newest = [...releases].sort((a, b) => b.created_at.localeCompare(a.created_at)); + return newest.slice(keep).filter((r) => r.tag_name !== current); +} + +function token(): string { + const value = process.env.CNB_TOKEN?.trim(); + if (!value) throw new Error("CNB_TOKEN is not set"); + return value; +} + +async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(path.startsWith("https://") ? path : API + path, { + method, + headers: { + authorization: `Bearer ${token()}`, + accept: "application/vnd.cnb.api+json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (res.status === 404 && method === "GET") return null; + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status} ${text}`); + return text ? (JSON.parse(text) as T) : null; +} + +async function withRetry(what: string, run: () => Promise, attempts = 3): Promise { + for (let i = 1; ; i++) { + try { + return await run(); + } catch (e) { + if (i >= attempts) throw e; + console.warn(`${what}: ${e instanceof Error ? e.message : e}; retrying (${i}/${attempts - 1})`); + await new Promise((r) => setTimeout(r, 5000 * i)); + } + } +} + +async function ensureRelease(tag: string, body: string, prerelease: boolean): Promise { + const existing = await api("GET", `/-/releases/tags/${encodeURIComponent(tag)}`); + if (existing) return existing.id; + const created = await api("POST", "/-/releases", { + tag_name: tag, + target_commitish: "main", + name: tag, + body, + prerelease, + make_latest: prerelease ? "false" : "true", + }); + return created!.id; +} + +async function upload(releaseId: string, name: string, file: Blob) { + const target = await api<{ upload_url: string; verify_url: string }>( + "POST", + `/-/releases/${releaseId}/asset-upload-url`, + { asset_name: name, size: file.size, overwrite: true }, + ); + const put = await fetch(target!.upload_url, { method: "PUT", body: file }); + if (!put.ok) throw new Error(`PUT ${name}: HTTP ${put.status} ${await put.text()}`); + await api("POST", target!.verify_url); +} + +async function uploadAll(releaseId: string, dir: string, names: string[]) { + const queue = [...names]; + const worker = async () => { + for (let name = queue.shift(); name; name = queue.shift()) { + const path = join(dir, name); + const mb = (statSync(path).size / 1048576).toFixed(1); + const started = Date.now(); + await withRetry(name, async () => upload(releaseId, name, await openAsBlob(path))); + console.log(`uploaded ${name} (${mb} MB, ${Math.round((Date.now() - started) / 1000)} s)`); + } + }; + await Promise.all(Array.from({ length: PARALLEL }, worker)); +} + +async function prune(current: string) { + const releases = (await api("GET", "/-/releases?page_size=100")) ?? []; + for (const old of releasesToPrune(releases, current)) { + await api("DELETE", `/-/releases/${old.id}`); + await api("DELETE", `/-/git/tags/${encodeURIComponent(old.tag_name)}`).catch((e) => console.warn(String(e))); + console.log(`removed ${old.tag_name}`); + } +} + +async function main() { + const [tag, dir] = process.argv.slice(2); + if (!tag || !dir) throw new Error("usage: node scripts/cnb-mirror.ts "); + const info = JSON.parse(readFileSync(join(dir, RELEASE_INFO), "utf8")) as { body?: string; isPrerelease?: boolean }; + const manifest = JSON.parse(readFileSync(join(dir, MANIFEST), "utf8")) as Manifest; + const mirrored = mirrorManifest(manifest, tag); + const assets = readdirSync(dir).filter( + (n) => n !== MANIFEST && n !== RELEASE_INFO && statSync(join(dir, n)).isFile(), + ); + + const releaseId = await ensureRelease(tag, info.body ?? "", !!info.isPrerelease); + console.log(`CNB release ${tag} (${releaseId}): uploading ${assets.length} assets`); + await uploadAll(releaseId, dir, assets); + const blob = new Blob([JSON.stringify(mirrored, null, 2)], { type: "application/json" }); + await withRetry(MANIFEST, () => upload(releaseId, MANIFEST, blob)); + console.log(`uploaded ${MANIFEST}: ${Object.keys(mirrored.platforms).length} platforms`); + await prune(tag); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }); +}