Compare commits

..
2 Commits
Author SHA1 Message Date
yyyyyynnn 94ab211431 release: prepare v0.3.2 2026-07-26 00:00:39 +08:00
yyyyyynnn e8b0e5b73c ci: stabilize updater release URLs 2026-07-24 16:06:57 +08:00
14 changed files with 420 additions and 47 deletions
+9 -3
View File
@@ -460,6 +460,8 @@ jobs:
--manifest latest.json \
--assets release-assets.json \
--version "$VERSION" \
--repository "$GITHUB_REPOSITORY" \
--release-tag "$RELEASE_TAG" \
--rewrite-download-urls \
--require-signature-assets
gh release upload "$RELEASE_TAG" latest.json --clobber
@@ -498,7 +500,9 @@ jobs:
python3 scripts/validate_updater_release.py \
--manifest published-latest.json \
--assets release-assets.json \
--version "$VERSION"
--version "$VERSION" \
--repository "$GITHUB_REPOSITORY" \
--release-tag "$RELEASE_TAG"
python3 - <<'PY' > updater-urls.txt
import json
@@ -507,9 +511,11 @@ jobs:
print(entry["url"])
PY
sort -u updater-urls.txt | while read -r url; do
curl -fsSLI \
curl -fsSL \
--range 0-0 \
--retry 5 \
--retry-delay 3 \
--retry-all-errors \
"$url" >/dev/null
"$url" \
-o /dev/null
done
+11
View File
@@ -2,6 +2,17 @@
All notable changes to Codex-X will be documented here.
## [v0.3.2] - 2026-07-25
### 修复 Bug
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
### 安全
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
## [v0.3.1] - 2026-07-24
### 更新
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -346,7 +346,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.1"
version = "0.3.2"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -8,10 +8,18 @@ import { readImageMetadata } from "./image-metadata.mjs";
const scriptPath = fileURLToPath(import.meta.url);
const here = path.dirname(scriptPath);
const root = path.resolve(here, "..");
const SKIN_VERSION = "1.2.3";
const SKIN_VERSION = "1.2.4";
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
const CDP_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
const MAX_ART_BYTES = 16 * 1024 * 1024;
const CODEX_SURFACE_SELECTORS = Object.freeze({
shell: "main.main-surface",
sidebar: "aside.app-shell-left-panel",
composer: ".composer-surface-chrome",
home: '[role="main"]:has([data-testid="home-icon"])',
appearance: 'input[name="appearance-theme"]',
themePreview: '[data-testid="theme-preview"]',
});
let staticPayloadAssets = null;
function parseArgs(argv) {
@@ -203,29 +211,36 @@ async function listAppTargets(port) {
}
}
async function probeSession(session) {
export async function probeSession(session) {
return session.evaluate(`(() => {
const markers = {
shell: Boolean(document.querySelector('main.main-surface')),
sidebar: Boolean(document.querySelector('aside.app-shell-left-panel')),
composer: Boolean(document.querySelector('.composer-surface-chrome')),
main: Boolean(document.querySelector('[role="main"]')),
shell: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)})),
sidebar: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)})),
composer: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.composer)})),
main: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)})),
settings: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)})) ||
Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)})),
};
return {
title: document.title,
href: location.href,
markers,
codex: markers.shell && markers.sidebar,
codex: location.protocol === 'app:' &&
((markers.shell && markers.sidebar) || markers.main || markers.settings),
};
})()`);
}
async function waitForCodexProbe(session, timeoutMs = 1800) {
export async function waitForCodexProbe(session, timeoutMs = 1800) {
const deadline = Date.now() + timeoutMs;
let probe = null;
while (Date.now() < deadline) {
probe = await probeSession(session);
if (probe?.codex) return probe;
try {
probe = await probeSession(session);
if (probe?.codex) return probe;
} catch {
// Chromium can briefly replace the execution context while Codex boots.
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return probe;
@@ -246,7 +261,8 @@ async function connectCodexTargets(port, timeoutMs) {
let session;
try {
session = await connectTarget(target, port);
const probe = await probeSession(session);
const remainingMs = Math.max(50, deadline - Date.now());
const probe = await waitForCodexProbe(session, Math.min(1800, remainingMs));
if (probe?.codex) connected.push({ target, session, probe });
else session.close();
} catch (error) {
@@ -255,7 +271,7 @@ async function connectCodexTargets(port, timeoutMs) {
}
}
if (connected.length) return connected;
lastError = new Error("No page matched the expected Codex shell markers");
lastError = new Error("No page matched the expected Codex surface markers");
} catch (error) {
lastError = error;
}
@@ -517,7 +533,7 @@ async function verifyRemovedSession(session) {
)()`);
}
async function verifySession(session) {
export async function verifySession(session) {
return session.evaluate(`(() => {
const box = (node) => {
if (!node) return null;
@@ -533,6 +549,8 @@ async function verifySession(session) {
const homeSignal = homeIndicator ?? document.querySelector('[data-feature="game-source"]') ??
document.querySelector('.group\\\\/home-suggestions');
const homeRoute = homeSignal?.closest('[role="main"]') ?? null;
const settingsAnchor = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
const home = document.querySelector('[role="main"].dream-skin-home');
const suggestions = home?.querySelector('.group\\\\/home-suggestions') ?? null;
const cardBoxes = suggestions ? [...suggestions.querySelectorAll('button')].map(box) : [];
@@ -550,7 +568,10 @@ async function verifySession(session) {
chromePresent: Boolean(chrome),
chromePointerEvents: getComputedStyle(chrome || document.body).pointerEvents,
homeRoute: Boolean(homeRoute),
homeRouteBox: box(homeRoute),
homePresent: Boolean(home),
settingsRoute: Boolean(settingsAnchor),
settingsAnchor: box(settingsAnchor),
hero,
cards: cardBoxes,
visibleCardCount: visibleCards.length,
@@ -564,9 +585,13 @@ async function verifySession(session) {
y: document.documentElement.scrollHeight > document.documentElement.clientHeight,
},
};
const standardSurface = Boolean(result.shell?.visible && result.sidebar?.visible);
const routeSurface = Boolean(result.homeRouteBox?.visible || result.settingsAnchor?.visible);
const chromePass = result.settingsRoute ||
(result.chromePresent && result.chromePointerEvents === 'none');
const basePass = result.installed && result.version === ${JSON.stringify(SKIN_VERSION)} &&
result.stylePresent && result.chromePresent && result.chromePointerEvents === 'none' &&
Boolean(result.shell?.visible) && Boolean(result.sidebar?.visible) && !result.documentOverflow.x;
result.stylePresent && chromePass && (standardSurface || routeSurface) &&
!result.documentOverflow.x;
// Project selector markup varies across Codex builds — soft requirement.
const homePass = !result.homeRoute || (
result.homePresent && result.hero?.visible && result.hero.width >= 280 && result.hero.height >= 120
@@ -688,9 +713,13 @@ export function earlyPayloadFor(payload, revision) {
const install = () => {
if (window[generationKey] !== generation) { stop(); return true; }
if (!document.documentElement) return false;
const shell = document.querySelector('main.main-surface');
const sidebar = document.querySelector('aside.app-shell-left-panel');
if (!shell || !sidebar) return false;
if (location.protocol !== "app:") return false;
const shell = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)});
const sidebar = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)});
const home = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)});
const settings = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
if (!(shell && sidebar) && !home && !settings) return false;
stop();
${payload};
window[appliedKey] = generation;
@@ -3,24 +3,33 @@ import fs from "node:fs/promises";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
import { earlyPayloadFor } from "../scripts/injector.mjs";
import {
earlyPayloadFor,
probeSession,
verifySession,
waitForCodexProbe,
} from "../scripts/injector.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
const source = await fs.readFile(injectorPath, "utf8");
function createFixture() {
function createFixture(protocol = "app:") {
const observers = [];
const timers = new Map();
let nextTimer = 1;
const markers = { shell: false, sidebar: false };
const markers = { shell: false, sidebar: false, home: false, settings: false, themePreview: false };
const context = {
window: { installs: [] },
location: { protocol },
document: {
documentElement: {},
querySelector(selector) {
if (selector === "main.main-surface") return markers.shell ? {} : null;
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
if (selector === '[role="main"]:has([data-testid="home-icon"])') return markers.home ? {} : null;
if (selector === 'input[name="appearance-theme"]') return markers.settings ? {} : null;
if (selector === '[data-testid="theme-preview"]') return markers.themePreview ? {} : null;
return null;
},
},
@@ -50,6 +59,26 @@ guarded.markers.shell = true;
guarded.observers[0].callback([]);
assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient.");
const home = createFixture();
home.markers.home = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("home")', "home"), home.context);
assert.deepEqual(home.context.window.installs, ["home"], "The explicit Codex home route must support early injection.");
const settings = createFixture();
settings.markers.settings = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("settings")', "settings"), settings.context);
assert.deepEqual(
settings.context.window.installs,
["settings"],
"The Codex appearance route must support early injection.",
);
const web = createFixture("https:");
web.markers.shell = true;
web.markers.sidebar = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("web")', "web"), web.context);
assert.deepEqual(web.context.window.installs, [], "Web pages must remain untouched even with spoofed shell markers.");
const generations = createFixture();
vm.runInNewContext(earlyPayloadFor('window.installs.push("old")', "old"), generations.context);
vm.runInNewContext(earlyPayloadFor('window.installs.push("new")', "new"), generations.context);
@@ -63,9 +92,123 @@ assert.deepEqual(
);
assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "new");
function createProbeSession(frames) {
let callCount = 0;
return {
get callCount() { return callCount; },
async evaluate(expression) {
const frame = frames[Math.min(callCount, frames.length - 1)];
callCount += 1;
if (frame instanceof Error) throw frame;
const marker = (key) => frame[key] ? {} : null;
return vm.runInNewContext(expression, {
location: { protocol: frame.protocol ?? "app:", href: `${frame.protocol ?? "app:"}//codex` },
document: {
title: "Codex",
querySelector(selector) {
if (selector === "main.main-surface") return marker("shell");
if (selector === "aside.app-shell-left-panel") return marker("sidebar");
if (selector === ".composer-surface-chrome") return marker("composer");
if (selector === '[role="main"]:has([data-testid="home-icon"])') return marker("home");
if (selector === 'input[name="appearance-theme"]') return marker("settings");
if (selector === '[data-testid="theme-preview"]') return marker("themePreview");
return null;
},
},
});
},
};
}
assert.equal((await probeSession(createProbeSession([{ shell: true, sidebar: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{ home: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{ settings: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{}]))).codex, false, "An unmarked app page must be rejected.");
assert.equal(
(await probeSession(createProbeSession([{ protocol: "https:", shell: true, sidebar: true }]))).codex,
false,
"A non-app page must be rejected even when it spoofs the standard shell.",
);
const delayed = createProbeSession([
new Error("Execution context was destroyed"),
{},
{ home: true },
]);
assert.equal((await waitForCodexProbe(delayed, 300))?.codex, true);
assert.equal(delayed.callCount, 3, "Probe waiting must survive document replacement and delayed DOM markers.");
function createVerificationSession(route) {
const node = (width = 640, height = 480) => ({
getBoundingClientRect: () => ({ x: 0, y: 0, width, height }),
querySelector: () => null,
});
const body = node();
const homeHero = node(560, 240);
const home = node();
home.firstElementChild = { firstElementChild: { firstElementChild: homeHero } };
const homeIndicator = { closest: () => home };
const settingsAnchor = node(320, 48);
const chrome = node();
return {
async evaluate(expression) {
return vm.runInNewContext(expression, {
innerWidth: 1280,
innerHeight: 800,
window: { __CODEX_DREAM_SKIN_STATE__: { version: "1.2.4" } },
getComputedStyle(target) {
return { display: "block", visibility: "visible", pointerEvents: target === chrome ? "none" : "auto" };
},
document: {
body,
documentElement: {
classList: { contains: () => true },
scrollWidth: 1280,
clientWidth: 1280,
scrollHeight: 800,
clientHeight: 800,
},
getElementById(id) {
if (id === "codex-dream-skin-style") return {};
if (id === "codex-dream-skin-chrome" && route === "home") return chrome;
return null;
},
querySelector(selector) {
if (selector === '[data-testid="home-icon"]' && route === "home") return homeIndicator;
if (selector === '[role="main"].dream-skin-home' && route === "home") return home;
if (selector === 'input[name="appearance-theme"]' && route === "settings") return settingsAnchor;
return null;
},
},
});
},
};
}
assert.equal(
(await verifySession(createVerificationSession("home"))).pass,
true,
"A verified Codex home route must not require the legacy shell/sidebar pair.",
);
assert.equal(
(await verifySession(createVerificationSession("settings"))).pass,
true,
"A verified appearance route must accept the installed root theme before the main shell mounts.",
);
assert.equal(
(await verifySession(createVerificationSession("auxiliary"))).pass,
false,
"An auxiliary app page must not pass post-install verification.",
);
const discoveryStart = source.indexOf("record.earlyScriptId = await registerEarly");
const probeStart = source.indexOf("const probe = await waitForCodexProbe", discoveryStart);
assert.ok(discoveryStart >= 0 && probeStart > discoveryStart, "Early registration must happen before full shell probing.");
assert.match(
source,
/connectCodexTargets[\s\S]*const probe = await waitForCodexProbe/,
"One-shot discovery must wait for a progressively loaded Codex renderer.",
);
assert.match(
source,
/finally\s*\{[\s\S]*Promise\.all\(\[\.\.\.sessions\.values\(\)\][\s\S]*removeEarly\(record\)/,
@@ -82,4 +225,4 @@ assert.match(
"One-shot commands must exit after flushing output so a lingering CDP close handshake cannot block Codex-X.",
);
console.log("PASS: early injection is shell-guarded, generation-safe, and removed on shutdown.");
console.log("PASS: renderer probing is route-aware, delayed-load safe, generation-safe, and guarded.");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.1",
"version": "0.3.2",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+8 -2
View File
@@ -43,9 +43,11 @@ export function useSkinCenter({
}: UseSkinCenterOptions) {
const [state, setState] = React.useState<SkinCenterState | null>(null);
const [restartRequest, setRestartRequest] = React.useState<SkinRestartRequest | null>(null);
const [pauseBusy, setPauseBusy] = React.useState(false);
const zipInputRef = React.useRef<HTMLInputElement | null>(null);
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
const loadedRef = React.useRef(false);
const pauseInFlightRef = React.useRef(false);
const load = React.useCallback(async ({
quiet = false,
@@ -193,7 +195,9 @@ export function useSkinCenter({
};
const pause = async () => {
setActionBusy("pauseSkin");
if (pauseInFlightRef.current) return;
pauseInFlightRef.current = true;
setPauseBusy(true);
setError("");
try {
const result = await invoke<SkinActionResult>("pause_skin_theme");
@@ -203,7 +207,8 @@ export function useSkinCenter({
setError(String(error));
void load({ quiet: true });
} finally {
setActionBusy("");
pauseInFlightRef.current = false;
setPauseBusy(false);
}
};
@@ -236,6 +241,7 @@ export function useSkinCenter({
return {
state,
restartRequest,
pauseBusy,
zipInputRef,
imageInputRef,
refresh,
+2
View File
@@ -799,6 +799,7 @@ function App() {
const {
state: skinCenterState,
restartRequest: skinRestartRequest,
pauseBusy: skinPauseBusy,
zipInputRef: skinZipImportRef,
imageInputRef: skinImageInputRef,
refresh: refreshSkinCenter,
@@ -2238,6 +2239,7 @@ function App() {
lang={lang}
state={skinCenterState}
actionBusy={actionBusy}
pauseBusy={skinPauseBusy}
zipInputRef={skinZipImportRef}
imageInputRef={skinImageInputRef}
onLoad={refreshSkinCenter}
+12 -9
View File
@@ -46,6 +46,7 @@ export type SkinsPageProps = {
lang: Lang;
state: SkinCenterState | null;
actionBusy: string;
pauseBusy: boolean;
zipInputRef: RefObject<HTMLInputElement>;
imageInputRef: RefObject<HTMLInputElement>;
onLoad: MaybeAsyncAction;
@@ -226,6 +227,7 @@ export function SkinsPage({
lang,
state,
actionBusy,
pauseBusy,
zipInputRef,
imageInputRef,
onLoad,
@@ -240,6 +242,7 @@ export function SkinsPage({
const themes = state?.themes ?? [];
const current = themes.find((theme) => theme.enabled);
const loading = actionBusy === "loadSkins";
const controlsBusy = Boolean(actionBusy) || pauseBusy;
const runtime = state?.runtime;
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
@@ -327,7 +330,7 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => setEditorOpen(true)}
disabled={Boolean(actionBusy)}
disabled={controlsBusy}
icon={<Pencil size={15} />}
>
{copy.edit}
@@ -335,7 +338,7 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => run(() => onEnableTheme(selectedTheme.id))}
disabled={Boolean(actionBusy) || selectedThemeApplied}
disabled={controlsBusy || selectedThemeApplied}
icon={actionBusy === `skin:${selectedTheme.id}`
? <Loader2 className="cx-skins-spin" size={15} />
: selectedThemeApplied ? <Check size={15} /> : <Play size={15} />}
@@ -347,7 +350,7 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => run(() => onExportTheme(selectedTheme.id))}
disabled={Boolean(actionBusy)}
disabled={controlsBusy}
icon={actionBusy === `skinExport:${selectedTheme.id}`
? <Loader2 className="cx-skins-spin" size={15} />
: <Download size={15} />}
@@ -359,15 +362,15 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => run(onPauseTheme)}
disabled={Boolean(actionBusy) || !runtime?.supported || !runtime.active}
icon={actionBusy === "pauseSkin" ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
disabled={pauseBusy}
icon={pauseBusy ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
>
{actionBusy === "pauseSkin" ? copy.pausing : copy.pause}
{pauseBusy ? copy.pausing : copy.pause}
</Button>
<Button
variant="secondary"
onClick={() => zipInputRef.current?.click()}
disabled={Boolean(actionBusy) || !state}
disabled={controlsBusy || !state}
icon={actionBusy === "importSkinZip" ? <Loader2 className="cx-skins-spin" size={15} /> : <Upload size={15} />}
>
{copy.importZip}
@@ -375,7 +378,7 @@ export function SkinsPage({
<Button
variant="primary"
onClick={() => imageInputRef.current?.click()}
disabled={Boolean(actionBusy) || !state}
disabled={controlsBusy || !state}
icon={actionBusy === "createSkinFromImage"
? <Loader2 className="cx-skins-spin" size={15} />
: <ImagePlus size={15} />}
@@ -401,7 +404,7 @@ export function SkinsPage({
variant="ghost"
size="sm"
onClick={() => run(onLoad)}
disabled={Boolean(actionBusy)}
disabled={controlsBusy}
/>
</section>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.1",
"version": "0.3.2",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import base64
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = ROOT / "scripts" / "validate_updater_release.py"
REPOSITORY = "example/Codex-X"
RELEASE_TAG = "v0.3.1"
VERSION = "0.3.1"
PLATFORM_ASSETS = {
"darwin-aarch64": "Codex-X.app.tar.gz",
"darwin-aarch64-app": "Codex-X.app.tar.gz",
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
"windows-x86_64": "Codex-X.msi",
"windows-x86_64-msi": "Codex-X.msi",
"linux-x86_64": "Codex-X.AppImage",
"linux-x86_64-deb": "Codex-X.deb",
"linux-x86_64-rpm": "Codex-X.rpm",
"linux-x86_64-appimage": "Codex-X.AppImage",
}
def draft_url(asset_name: str) -> str:
return (
"https://github.com/example/Codex-X/releases/download/"
f"untagged-test/{asset_name}"
)
class ValidateUpdaterReleaseTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.root = Path(self.temp_dir.name)
self.manifest_path = self.root / "latest.json"
self.assets_path = self.root / "assets.json"
signature = base64.b64encode(b"s" * 64).decode("ascii")
manifest = {
"version": VERSION,
"platforms": {
platform: {
"signature": signature,
"url": draft_url(asset_name),
}
for platform, asset_name in PLATFORM_ASSETS.items()
},
}
asset_names = sorted(set(PLATFORM_ASSETS.values()))
assets = [
{
"name": asset_name,
"url": f"https://api.github.com/assets/{index}",
"browser_download_url": draft_url(asset_name),
}
for index, asset_name in enumerate(asset_names, start=1)
]
assets.append(
{
"name": "latest.json",
"url": "https://api.github.com/assets/latest",
"browser_download_url": draft_url("latest.json"),
}
)
self.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
def run_validator(self, *extra_args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"python3",
str(VALIDATOR),
"--manifest",
str(self.manifest_path),
"--assets",
str(self.assets_path),
"--version",
VERSION,
"--repository",
REPOSITORY,
"--release-tag",
RELEASE_TAG,
*extra_args,
],
check=False,
capture_output=True,
text=True,
)
def test_rewrites_draft_urls_to_stable_release_tag(self) -> None:
result = self.run_validator("--rewrite-download-urls")
self.assertEqual(result.returncode, 0, result.stderr)
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
urls = {entry["url"] for entry in manifest["platforms"].values()}
self.assertTrue(urls)
self.assertTrue(
all("/releases/download/v0.3.1/" in url for url in urls),
urls,
)
self.assertTrue(all("untagged-" not in url for url in urls), urls)
def test_rejects_draft_urls_without_rewrite(self) -> None:
result = self.run_validator()
self.assertNotEqual(result.returncode, 0)
self.assertIn("stable release tag download URL", result.stderr)
def test_repairs_stale_urls_after_release_is_published(self) -> None:
assets = json.loads(self.assets_path.read_text(encoding="utf-8"))
for asset in assets:
asset["browser_download_url"] = (
f"https://github.com/{REPOSITORY}/releases/download/"
f"{RELEASE_TAG}/{asset['name']}"
)
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
result = self.run_validator("--rewrite-download-urls")
self.assertEqual(result.returncode, 0, result.stderr)
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
self.assertTrue(
all(
"/releases/download/v0.3.1/" in entry["url"]
for entry in manifest["platforms"].values()
)
)
if __name__ == "__main__":
unittest.main()
+43 -6
View File
@@ -9,6 +9,7 @@ import binascii
import json
from pathlib import Path
from typing import Any
from urllib.parse import quote, unquote, urlparse
REQUIRED_PLATFORMS = {
@@ -48,10 +49,20 @@ def validate_signature(platform: str, value: Any) -> None:
fail(f"{platform} signature is unexpectedly short")
def canonical_asset_url(repository: str, release_tag: str, asset_name: str) -> str:
return (
f"https://github.com/{repository}/releases/download/"
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
)
def rewrite_download_urls(
manifest_path: Path,
manifest: dict[str, Any],
assets_by_url: dict[str, dict[str, Any]],
assets_by_name: dict[str, dict[str, Any]],
repository: str,
release_tag: str,
) -> int:
platforms = manifest.get("platforms")
if not isinstance(platforms, dict):
@@ -65,11 +76,14 @@ def rewrite_download_urls(
if not isinstance(current_url, str):
fail(f"{platform} has an invalid download URL")
asset = assets_by_url.get(current_url)
if asset is None:
parsed_url = urlparse(current_url)
asset_name = unquote(parsed_url.path.rsplit("/", 1)[-1])
if parsed_url.scheme == "https" and parsed_url.netloc == "github.com":
asset = assets_by_name.get(asset_name)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
download_url = asset.get("browser_download_url")
if not isinstance(download_url, str) or not download_url.startswith("https://"):
fail(f"{platform} asset has no public download URL")
download_url = canonical_asset_url(repository, release_tag, asset["name"])
if current_url != download_url:
entry["url"] = download_url
rewritten += 1
@@ -86,10 +100,16 @@ def main() -> None:
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--assets", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--release-tag", required=True)
parser.add_argument("--rewrite-download-urls", action="store_true")
parser.add_argument("--require-signature-assets", action="store_true")
args = parser.parse_args()
repository_parts = args.repository.split("/")
if len(repository_parts) != 2 or not all(repository_parts):
fail(f"invalid GitHub repository {args.repository!r}")
manifest = load_json(args.manifest)
assets = load_json(args.assets)
if not isinstance(manifest, dict):
@@ -102,15 +122,20 @@ def main() -> None:
)
asset_names: set[str] = set()
assets_by_name: dict[str, dict[str, Any]] = {}
assets_by_url: dict[str, dict[str, Any]] = {}
for asset in assets:
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
fail("release assets response contains an invalid entry")
asset_names.add(asset["name"])
assets_by_name[asset["name"]] = asset
for field in ("url", "browser_download_url"):
value = asset.get(field)
if isinstance(value, str) and value:
assets_by_url[value] = asset
assets_by_url[
canonical_asset_url(args.repository, args.release_tag, asset["name"])
] = asset
if "latest.json" not in asset_names:
fail("the draft release does not contain latest.json")
@@ -120,7 +145,14 @@ def main() -> None:
fail("latest.json has no platforms object")
if args.rewrite_download_urls:
rewritten = rewrite_download_urls(args.manifest, manifest, assets_by_url)
rewritten = rewrite_download_urls(
args.manifest,
manifest,
assets_by_url,
assets_by_name,
args.repository,
args.release_tag,
)
print(f"Rewrote {rewritten} updater asset URLs to public download URLs.")
for platform, entry in platforms.items():
@@ -133,8 +165,13 @@ def main() -> None:
asset = assets_by_url.get(url)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
if url != asset.get("browser_download_url"):
fail(f"{platform} URL points to GitHub metadata instead of the asset download")
expected_url = canonical_asset_url(
args.repository,
args.release_tag,
asset["name"],
)
if url != expected_url:
fail(f"{platform} URL does not use the stable release tag download URL")
for platform, suffix in REQUIRED_PLATFORMS.items():
entry = platforms.get(platform)