diff --git a/.github/workflows/mirror-release-to-r2.yml b/.github/workflows/mirror-release-to-r2.yml new file mode 100644 index 000000000..d2fb0f51d --- /dev/null +++ b/.github/workflows/mirror-release-to-r2.yml @@ -0,0 +1,58 @@ +name: mirror-release-to-r2 + +# Mirror a published release's installers to Cloudflare R2 so the China landing +# page (site/) serves fast CDN-cached direct downloads. Gated by verify-release: +# if the release is incomplete the mirror fails closed and the site keeps +# pointing at the previous good release. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to mirror (e.g. v2026.5.29)" + required: true + +concurrency: + group: mirror-r2-${{ github.event.release.tag_name || github.event.inputs.tag }} + cancel-in-progress: false + +permissions: + contents: read + +env: + R2_BUCKET: pawwork-downloads + DOWNLOAD_PUBLIC_BASE: https://dl.pawwork.ai + # Pass the tag via env, never interpolate ${{ }} into a run: script — an + # attacker-controlled tag must reach the secrets-bearing steps as data, not + # shell. Steps reference "$TAG" only. + TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} + +jobs: + mirror: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + # Load-bearing for `bun audit` exit semantics; do not bump without re-verifying advisory exit codes. + bun-version: "1.3.14" + + - name: Verify release is complete (fail closed) + run: bun packages/desktop-electron/scripts/verify-release.ts "$TAG" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Mirror release assets to R2 + run: bun packages/desktop-electron/scripts/mirror-release-to-r2.ts "$TAG" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto diff --git a/packages/desktop-electron/scripts/mirror-release-to-r2.test.ts b/packages/desktop-electron/scripts/mirror-release-to-r2.test.ts new file mode 100644 index 000000000..b592c3847 --- /dev/null +++ b/packages/desktop-electron/scripts/mirror-release-to-r2.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import { releaseAssetNames } from "./verify-release.ts" +import { buildManifest, uploadPlan } from "./mirror-release-to-r2.ts" + +describe("buildManifest", () => { + test("locks the manifest shape and per-platform installer URLs", () => { + expect(buildManifest("2026.5.29", "https://dl.pawwork.ai")).toEqual({ + version: "2026.5.29", + macArm64: "https://dl.pawwork.ai/pawwork-mac-arm64-2026.5.29.dmg", + macX64: "https://dl.pawwork.ai/pawwork-mac-x64-2026.5.29.dmg", + winX64: "https://dl.pawwork.ai/pawwork-win-x64-2026.5.29.exe", + }) + }) + + test("normalizes a trailing slash in the public base", () => { + expect(buildManifest("2026.5.29", "https://dl.pawwork.ai/").macArm64).toBe( + "https://dl.pawwork.ai/pawwork-mac-arm64-2026.5.29.dmg", + ) + }) +}) + +describe("uploadPlan", () => { + const plan = uploadPlan(releaseAssetNames("2026.5.29")) + const names = plan.map((step) => step.name) + + test("ends with the landing-page manifest as the single live switch", () => { + expect(names.at(-1)).toBe("latest.json") + expect(plan.at(-1)).toMatchObject({ manifest: true, cacheControl: "no-cache, must-revalidate" }) + }) + + test("orders immutable versioned artifacts before the mutable updater pointers", () => { + const lastVersioned = Math.max( + names.indexOf("pawwork-mac-arm64-2026.5.29.dmg"), + names.indexOf("pawwork-win-x64-2026.5.29.exe"), + names.indexOf("pawwork-mac-arm64-2026.5.29.zip.blockmap"), + ) + const firstPointer = Math.min(names.indexOf("latest.yml"), names.indexOf("latest-mac.yml")) + expect(lastVersioned).toBeLessThan(firstPointer) + expect(firstPointer).toBeLessThan(names.indexOf("latest.json")) + }) + + test("marks versioned artifacts immutable and pointers no-cache", () => { + const cacheOf = (name: string) => plan.find((step) => step.name === name)?.cacheControl + expect(cacheOf("pawwork-mac-arm64-2026.5.29.dmg")).toBe("public, max-age=31536000, immutable") + expect(cacheOf("latest.yml")).toBe("no-cache, must-revalidate") + expect(cacheOf("latest-mac.yml")).toBe("no-cache, must-revalidate") + }) + + test("uploads every released asset exactly once plus the manifest", () => { + const assets = releaseAssetNames("2026.5.29") + expect(names.slice(0, -1).sort()).toEqual([...assets].sort()) + expect(new Set(names).size).toBe(names.length) + }) +}) + +describe("mirror workflow shell-injection guard", () => { + const workflow = readFileSync( + join(import.meta.dir, "..", "..", "..", ".github", "workflows", "mirror-release-to-r2.yml"), + "utf8", + ) + + test("never interpolates a GitHub expression into a run: command", () => { + // An attacker-controlled tag must reach the secrets-bearing steps as data + // ($TAG), never as shell text — ${{ }} in a run: line allows injection. + const offending = workflow + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .filter((line) => line.includes("run:") && line.includes("${{")) + expect(offending).toEqual([]) + }) + + test("passes the tag to scripts via the quoted env var", () => { + expect(workflow).toContain('verify-release.ts "$TAG"') + expect(workflow).toContain('mirror-release-to-r2.ts "$TAG"') + }) +}) diff --git a/packages/desktop-electron/scripts/mirror-release-to-r2.ts b/packages/desktop-electron/scripts/mirror-release-to-r2.ts new file mode 100644 index 000000000..c29afe709 --- /dev/null +++ b/packages/desktop-electron/scripts/mirror-release-to-r2.ts @@ -0,0 +1,171 @@ +// Mirror a published GitHub Release's installers to Cloudflare R2 so the China +// landing page (site/) can serve fast, CDN-cached direct downloads. +// +// Ordering matters (see PR discussion): versioned installer objects are +// immutable and uploaded first; the mutable pointers — the electron-updater +// latest*.yml and finally the landing page's latest.json — are written last, +// so a failure leaves the site pointing at the previous good release rather +// than a half-mirrored one. Run AFTER verify-release.ts has confirmed the +// release is complete. +// +// Usage: bun packages/desktop-electron/scripts/mirror-release-to-r2.ts [owner/repo] +// Env: +// R2_ACCOUNT_ID Cloudflare account id (S3 endpoint host) +// R2_BUCKET target bucket, e.g. pawwork-downloads +// DOWNLOAD_PUBLIC_BASE public base URL, e.g. https://dl.pawwork.ai +// AWS_ACCESS_KEY_ID R2 token access key (S3-compatible) +// AWS_SECRET_ACCESS_KEY R2 token secret +// GH_TOKEN GitHub token for `gh release download` + +import { mkdtemp, readdir, rm, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { releaseAssetNames } from "./verify-release.ts" + +const MUTABLE_POINTERS = new Set(["latest.yml", "latest-mac.yml"]) +const IMMUTABLE_CACHE = "public, max-age=31536000, immutable" +const POINTER_CACHE = "no-cache, must-revalidate" +const MANIFEST_NAME = "latest.json" + +export type UploadStep = { name: string; cacheControl: string; manifest?: boolean } + +// Pointer object the landing page reads to swap download buttons to R2 links. +// Keys match the data-dl attributes on the buttons in site/src/pages/index.astro. +export function buildManifest(version: string, publicBase: string) { + const base = publicBase.replace(/\/$/, "") + return { + version, + macArm64: `${base}/pawwork-mac-arm64-${version}.dmg`, + macX64: `${base}/pawwork-mac-x64-${version}.dmg`, + winX64: `${base}/pawwork-win-x64-${version}.exe`, + } +} + +// Ordered upload plan: immutable versioned artifacts first, then the mutable +// electron-updater pointers, then the landing-page manifest LAST — the single +// switch that makes a release live. The order is load-bearing (a half-mirror +// must never point the site at incomplete artifacts); locked by the test. +export function uploadPlan(assets: string[]): UploadStep[] { + const versioned = assets + .filter((name) => !MUTABLE_POINTERS.has(name)) + .map((name) => ({ name, cacheControl: IMMUTABLE_CACHE })) + const pointers = assets + .filter((name) => MUTABLE_POINTERS.has(name)) + .map((name) => ({ name, cacheControl: POINTER_CACHE })) + return [...versioned, ...pointers, { name: MANIFEST_NAME, cacheControl: POINTER_CACHE, manifest: true }] +} + +const CONTENT_TYPES: Record = { + dmg: "application/x-apple-diskimage", + exe: "application/octet-stream", + zip: "application/zip", + yml: "text/yaml", + json: "application/json", + blockmap: "application/octet-stream", +} + +function contentTypeFor(name: string) { + const ext = name.split(".").pop() ?? "" + return CONTENT_TYPES[ext] ?? "application/octet-stream" +} + +function requireEnv(key: string): string { + const value = process.env[key] + if (!value) throw new Error(`Missing required env: ${key}`) + return value +} + +async function run(cmd: string[]): Promise { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(`Command failed (${code}): ${cmd.join(" ")}\n${stderr}`) + return stdout +} + +async function main() { + const tag = process.argv[2] + if (!tag) { + console.error("Usage: bun packages/desktop-electron/scripts/mirror-release-to-r2.ts [owner/repo]") + process.exit(1) + } + const repo = process.argv[3] ?? "Astro-Han/pawwork" + const version = tag.replace(/^v/, "") + + const accountId = requireEnv("R2_ACCOUNT_ID") + const bucket = requireEnv("R2_BUCKET") + const publicBase = requireEnv("DOWNLOAD_PUBLIC_BASE").replace(/\/$/, "") + requireEnv("AWS_ACCESS_KEY_ID") + requireEnv("AWS_SECRET_ACCESS_KEY") + const endpoint = `https://${accountId}.r2.cloudflarestorage.com` + + const assets = releaseAssetNames(version) + + const dir = await mkdtemp(join(tmpdir(), "pawwork-r2-")) + try { + await mirror({ assets, tag, repo, dir, bucket, endpoint, publicBase, version }) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +type MirrorArgs = { + assets: string[] + tag: string + repo: string + dir: string + bucket: string + endpoint: string + publicBase: string + version: string +} + +async function mirror({ assets, tag, repo, dir, bucket, endpoint, publicBase, version }: MirrorArgs) { + console.log(`Downloading ${assets.length} assets of ${tag} from ${repo} ...`) + for (const name of assets) { + await run(["gh", "release", "download", tag, "--repo", repo, "--pattern", name, "--dir", dir]) + } + const present = new Set(await readdir(dir)) + const missing = assets.filter((name) => !present.has(name)) + if (missing.length) throw new Error(`Assets missing after download: ${missing.join(", ")}`) + + const upload = async (name: string, cacheControl: string) => { + const local = join(dir, name) + await run([ + "aws", "s3", "cp", local, `s3://${bucket}/${name}`, + "--endpoint-url", endpoint, + "--content-type", contentTypeFor(name), + "--cache-control", cacheControl, + "--no-progress", + ]) + const head = JSON.parse( + await run(["aws", "s3api", "head-object", "--bucket", bucket, "--key", name, "--endpoint-url", endpoint]), + ) + const localSize = (await stat(local)).size + if (head.ContentLength !== localSize) { + throw new Error(`Size mismatch for ${name}: local ${localSize} vs R2 ${head.ContentLength}`) + } + console.log(` ✓ ${name} (${localSize} bytes)`) + } + + // Upload in the load-bearing order (versioned -> updater pointers -> manifest + // last). The manifest is generated just before its upload. + for (const step of uploadPlan(assets)) { + if (step.manifest) { + await Bun.write(join(dir, step.name), JSON.stringify(buildManifest(version, publicBase), null, 2)) + } + await upload(step.name, step.cacheControl) + } + + console.log(`Mirrored ${tag} to ${publicBase} (latest.json -> ${version}).`) +} + +if (import.meta.main) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) + }) +} diff --git a/packages/opencode/test/github/bun-version-workflow.test.ts b/packages/opencode/test/github/bun-version-workflow.test.ts index e16d0358d..e6d1818c1 100644 --- a/packages/opencode/test/github/bun-version-workflow.test.ts +++ b/packages/opencode/test/github/bun-version-workflow.test.ts @@ -94,6 +94,7 @@ describe("GitHub workflow Bun version pin", () => { ".github/workflows/desktop-smoke.yml:smoke-macos-arm64:step-3:bun-version: \"1.3.14\"", ".github/workflows/dev-dep-audit.yml:dev-dep-audit:step-3:bun-version: \"1.3.14\"", ".github/workflows/e2e-artifacts.yml:e2e-artifacts:step-3:bun-version: \"1.3.14\"", + ".github/workflows/mirror-release-to-r2.yml:mirror:step-2:bun-version: \"1.3.14\"", ".github/workflows/officecli-bump.yml:officecli-bump:step-3:bun-version: \"1.3.14\"", ".github/workflows/perf-probe-baseline.yml:perf-probe-baseline:step-7:bun-version: \"1.3.14\"", ".github/workflows/windows-advisory.yml:unit-windows:step-3:bun-version: \"1.3.14\"", diff --git a/site/src/config.ts b/site/src/config.ts index 6eea3b937..8ba8b3133 100644 --- a/site/src/config.ts +++ b/site/src/config.ts @@ -1,7 +1,12 @@ -// Site-level constants. Download links currently point at the GitHub Releases -// page, where users pick the installer themselves. Once China-hosted storage -// (R2 / COS) and the updater fallback (issue #219) land, swap mac / macIntel / -// win for the per-platform direct links — nothing else on the page changes. +// Site-level constants. +// +// DOWNLOAD holds the fallback download targets: the GitHub Releases page, where +// users pick the installer themselves. On load the client fetches +// DOWNLOAD_MANIFEST_URL — a tiny JSON pointer in Cloudflare R2 that the release +// workflow rewrites every release — and, if reachable, swaps the buttons for +// per-platform direct links on the China-accessible Cloudflare CDN. If the +// manifest is unreachable (R2 down, offline, blocked), the GitHub fallback +// stands, so the buttons always work. export const REPO_URL = "https://github.com/Astro-Han/pawwork"; export const RELEASES_URL = `${REPO_URL}/releases/latest`; @@ -11,3 +16,8 @@ export const DOWNLOAD = { macIntel: RELEASES_URL, win: RELEASES_URL, }; + +// Pointer object mirrored to R2 by .github/workflows/mirror-release-to-r2.yml. +// Shape: { version, macArm64, macX64, winX64 } where each value is a direct URL. +// Keys match the data-dl attributes on the download buttons in index.astro. +export const DOWNLOAD_MANIFEST_URL = "https://dl.pawwork.ai/latest.json"; diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index e2244b5f6..04ca441c6 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -1,7 +1,7 @@ --- import Base from "../layouts/Base.astro"; import { I18N } from "../i18n"; -import { DOWNLOAD, REPO_URL, RELEASES_URL } from "../config"; +import { DOWNLOAD, DOWNLOAD_MANIFEST_URL, REPO_URL, RELEASES_URL } from "../config"; // First paint renders English (basic SEO); the client then switches by browser // language or the user's toggle. @@ -71,9 +71,9 @@ const seoDesc =

- - - + + +

@@ -111,7 +111,7 @@ const seoDesc =
-