Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ name: CI

# Fast pull-request gate: type-check, unit tests and a cross-platform Rust
# clippy/test pass. The heavy signed/notarized bundle build stays in
# release.yml (tags only) — this never builds or signs the .app.
# the default-branch release.yml (signalled by v* tags) — this never builds or
# signs the .app.
on:
pull_request:
push:
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/release-source.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Release source

# This workflow is only an unprivileged signal. Tag workflows are loaded from
# the tagged commit, so this file must never receive an environment, secrets,
# write permissions, checkout, or artifacts. The credentialed Release workflow
# is triggered with workflow_run and is therefore loaded from the default branch.
on:
push:
tags: ["v*"]

permissions: {}

jobs:
signal:
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Validate release signal shape
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG"
exit 1
fi
if [[ ! "$RELEASE_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::release source must be a full commit SHA"
exit 1
fi
echo "Unprivileged release signal accepted for $RELEASE_TAG at $RELEASE_SOURCE_SHA"
1,090 changes: 1,015 additions & 75 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

31 changes: 23 additions & 8 deletions cloudflare/manager-download-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ The updater (`src-tauri/tauri.conf.json`) already checks
## Why a separate latest.json on the mirror
`latest.json`'s embedded signatures sign the artifact **bytes**, not the URL, so
re-hosting the same files keeps them valid — we only rewrite the download URLs to
`…/manager/<version>/<file>`. `scripts/sync-mirror.sh` does this on every
(non-pre-)release.
`…/manager/<version>/<file>`. `scripts/sync-mirror.sh` stages stable releases,
then verifies both storage endpoints and separately forces this Worker's R2 and
IHEP branches before the GitHub Release becomes immutable. Probe responses identify
their backend with `X-Codex-Mirror-Backend`; an explicit IHEP probe fails instead
of falling back when its Worker secrets are incomplete. The release repeats that
readback immediately before advancing the mirror's `latest.json` pointer.

Installers are uploaded under a **per-version** key (`<version>/<file>`) and
`latest.json` stays at the fixed root the updater polls. macOS updater tarballs
Expand All @@ -31,7 +35,7 @@ self-consistent; v0.1.9+ use the versioned layout.)

> ⚠️ The mirror's `latest.json` MUST be refreshed on every stable release, or the
> first endpoint serves a stale version and **blocks** updates. That's why the
> `release.yml` "Sync to CDN mirror" step exists.
> `release.yml` stage, verify, and promote steps exist.

## Already provisioned (done)
- R2 bucket `codex-app-manager` created.
Expand Down Expand Up @@ -59,17 +63,28 @@ So each release uploads to the mirror (`release.yml` → `scripts/sync-mirror.sh
| Secret | Notes |
| --- | --- |
| `MANAGER_R2_S3_ENDPOINT` | `https://d39dc6c92d1c4cfde580bf13e946b616.r2.cloudflarestorage.com` |
| `MANAGER_R2_ACCESS_KEY_ID` / `MANAGER_R2_SECRET_ACCESS_KEY` | R2 S3 API token (can reuse the mirror's; account-scoped) |
| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` / `_ACCESS_KEY_ID` / `_SECRET_ACCESS_KEY` | IHEP creds (optional; `_REGION`/`_PREFIX` optional) |
| `MANAGER_R2_PROMOTION_ACCESS_KEY_ID` / `MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY` | R2 S3 API token for the current protected release workflow |
| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` environment variables | IHEP endpoint and bucket (`_REGION`/`_PREFIX` variables optional) |
| `MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID` / `MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY` | IHEP token for the current protected release workflow |

R2 secrets missing → step warns and skips (mirror goes stale). IHEP missing →
CN just falls back to R2 via the worker.
Both backends must already contain the same valid `latest.json` baseline and
preserve custom user metadata through `HeadObject`. R2 is the sole CAS authority
and must enforce conditional `PutObject` (`If-Match` / `If-None-Match`). IHEP is
the serialized unconditional follower; its ignored conditional headers are not a
promotion prerequisite. Delete the legacy access-key secret names after migration
so historical workflow revisions cannot perform their old unconditional write.

Deploy this Worker revision before enabling the protected release workflow. The
release gate intentionally rejects an older Worker that cannot force and identify
both public backend branches.

## Deploy / manual sync
```bash
wrangler deploy # from this directory
# manual re-sync of a tag's assets (download release → rewrite → upload):
# gh release download vX.Y.Z -D dist && cp -f dist/latest.json dist/ && \
# rm -rf dist && gh release download vX.Y.Z -D dist && \
# MANAGER_R2_S3_ENDPOINT=… MANAGER_R2_ACCESS_KEY_ID=… MANAGER_R2_SECRET_ACCESS_KEY=… \
# MANAGER_IHEP_S3_ENDPOINT=… MANAGER_IHEP_S3_BUCKET=… \
# MANAGER_IHEP_S3_ACCESS_KEY_ID=… MANAGER_IHEP_S3_SECRET_ACCESS_KEY=… \
# bash ../../scripts/sync-mirror.sh dist
```
38 changes: 34 additions & 4 deletions cloudflare/manager-download-router/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default {
}

const country = request.cf?.country || request.headers.get("CF-IPCountry") || "";
const probeBackend = requestedProbeBackend(url);
const secondaryCountryCodes = new Set(
(env.SECONDARY_COUNTRY_CODES || DEFAULT_SECONDARY_COUNTRY_CODES)
.split(",")
Expand All @@ -46,7 +47,19 @@ export default {
);

// ── Mainland China: presign the IHEP S3 object and redirect ──────────────
if (secondaryCountryCodes.has(country.toUpperCase()) && hasSecondaryS3Config(env)) {
const secondaryConfigured = hasSecondaryS3Config(env);
if (probeBackend === "ihep" && !secondaryConfigured) {
return new Response("Secondary mirror is not configured", {
status: 503,
headers: { "X-Codex-Mirror-Backend": "ihep" },
});
}
const useSecondary =
probeBackend === "ihep" ||
(probeBackend !== "r2" &&
secondaryCountryCodes.has(country.toUpperCase()) &&
secondaryConfigured);
if (useSecondary) {
const objectKey = objectKeyForKey(key, env.SECONDARY_S3_PREFIX || "");
const signedUrl = await presignS3Url({
// Sign for the actual method: the redirect preserves it, and an
Expand All @@ -62,7 +75,7 @@ export default {
expiresInSeconds: ttlSeconds(env.SECONDARY_S3_SIGNED_URL_TTL_SECONDS),
responseHeaders: {},
});
return redirect(signedUrl);
return redirect(signedUrl, "ihep");
}

// ── Everyone else: stream straight from the bound R2 bucket ──────────────
Expand All @@ -74,6 +87,7 @@ export default {
object.writeHttpMetadata(headers); // Content-Type etc. from R2 metadata
headers.set("ETag", object.httpEtag);
headers.set("Cache-Control", cacheControlForKey(key));
headers.set("X-Codex-Mirror-Backend", "r2");
if (request.method === "HEAD") {
return new Response(null, { headers });
}
Expand All @@ -97,16 +111,32 @@ function hasSecondaryS3Config(env) {
);
}

// Release verification must exercise both geographic branches from a single
// GitHub runner. This is a routing selector, not an authorization mechanism:
// every addressed object is already public through this Worker. Requiring the
// run-specific probe token shape prevents ordinary download links from changing
// backend accidentally while allowing CI to force and identify each branch.
function requestedProbeBackend(url) {
const token = url.searchParams.get("cam_probe") || "";
const backend = url.searchParams.get("cam_backend") || "";
if (!/^[0-9a-f]{24}$/.test(token)) return null;
return ["r2", "ihep"].includes(backend) ? backend : null;
}

function ttlSeconds(value) {
const parsed = Number.parseInt(value || DEFAULT_SIGNED_URL_TTL_SECONDS, 10);
if (!Number.isFinite(parsed)) return DEFAULT_SIGNED_URL_TTL_SECONDS;
return Math.min(Math.max(parsed, 1), 604800);
}

function redirect(location) {
function redirect(location, backend) {
return new Response(null, {
status: 302,
headers: { Location: location, "Cache-Control": "private, no-store" },
headers: {
Location: location,
"Cache-Control": "private, no-store",
"X-Codex-Mirror-Backend": backend,
},
});
}

Expand Down
40 changes: 40 additions & 0 deletions cloudflare/manager-download-router/test/index.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import worker from "../src/index.js";

const PROBE_TOKEN = "0123456789abcdef01234567";

const latestManifest = JSON.stringify({
version: "0.1.18",
platforms: {
Expand Down Expand Up @@ -140,6 +142,7 @@ describe("manager download router", () => {
const jsonRes = await worker.fetch(request("/manager/0.1.18/latest.json"), jsonEnv);
expect(jsonRes.status).toBe(200);
expect(jsonRes.headers.get("Cache-Control")).toBe("public, max-age=120, s-maxage=120");
expect(jsonRes.headers.get("X-Codex-Mirror-Backend")).toBe("r2");

const installerEnv = {
BUCKET: bucket({
Expand All @@ -166,6 +169,7 @@ describe("manager download router", () => {
);

expect(res.status).toBe(302);
expect(res.headers.get("X-Codex-Mirror-Backend")).toBe("ihep");
const location = new URL(res.headers.get("Location"));
expect(location.origin).toBe("https://s3.example.test");
expect(location.pathname).toBe("/root/mirror-bucket/manager/0.1.18/CodexAppManager_0.1.18_x64-setup.exe");
Expand Down Expand Up @@ -195,6 +199,42 @@ describe("manager download router", () => {
expect(calls).toEqual(["0.1.18/CodexAppManager_0.1.18_x64-setup.exe"]);
});

it("lets release probes force and identify both geographic backends", async () => {
const calls = [];
const path = "/manager/0.1.18/CodexAppManager_0.1.18_x64-setup.exe";
const env = {
BUCKET: bucket(
{ "0.1.18/CodexAppManager_0.1.18_x64-setup.exe": r2Object("installer") },
calls,
),
...secondaryEnv(),
};

const forcedR2 = await worker.fetch(
request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=r2`, {}, { country: "CN" }),
env,
);
expect(forcedR2.status).toBe(200);
expect(forcedR2.headers.get("X-Codex-Mirror-Backend")).toBe("r2");
expect(await forcedR2.text()).toBe("installer");

const forcedIhep = await worker.fetch(
request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=ihep`, {}, { country: "US" }),
env,
);
expect(forcedIhep.status).toBe(302);
expect(forcedIhep.headers.get("X-Codex-Mirror-Backend")).toBe("ihep");
expect(new URL(forcedIhep.headers.get("Location")).origin).toBe("https://s3.example.test");
expect(calls).toEqual(["0.1.18/CodexAppManager_0.1.18_x64-setup.exe"]);

const missingSecondary = await worker.fetch(
request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=ihep`),
{ BUCKET: bucket({}), ...secondaryEnv({ SECONDARY_S3_SECRET_ACCESS_KEY: "" }) },
);
expect(missingSecondary.status).toBe(503);
expect(missingSecondary.headers.get("X-Codex-Mirror-Backend")).toBe("ihep");
});

it("rejects empty keys, directory keys, and traversal-looking keys", async () => {
const calls = [];
const env = { BUCKET: bucket({}, calls) };
Expand Down
Loading
Loading