From 0e7a63f83f2f7183333fa7c795b0311c3414b9a3 Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 16:02:24 -0400 Subject: [PATCH] governance: centralize release metadata and source reviews --- .../ISSUE_TEMPLATE/accessibility-report.yml | 44 +++ .github/ISSUE_TEMPLATE/bug-report.yml | 63 ++++ .../ISSUE_TEMPLATE/compatibility-report.yml | 55 ++++ .github/ISSUE_TEMPLATE/performance-report.yml | 51 ++++ .github/workflows/desktop.yml | 9 +- README.md | 17 +- README.zh-CN.md | 17 +- apps/theme-manager/src-tauri/src/catalog.rs | 202 ++++++++++++- apps/theme-manager/src-tauri/tauri.conf.json | 11 +- apps/theme-manager/src/renderer/app.js | 29 ++ apps/theme-manager/src/renderer/index.html | 1 + apps/theme-manager/src/renderer/styles.css | 7 +- docs/CHANGELOG.md | 10 + docs/adapters.md | 8 +- docs/art-direction.md | 6 +- docs/desktop-manager.md | 8 +- docs/release-metadata.md | 26 ++ docs/release-signing.md | 28 +- metadata/release.json | 138 +++++++++ package.json | 6 +- schemas/registry.schema.json | 152 +++++++++- schemas/source-art-review.schema.json | 77 +++++ schemas/theme-pack.schema.json | 76 ++++- scripts/build.mjs | 24 +- scripts/capture-full-skin-screenshots.mjs | 16 +- scripts/lib/codex-native-theme.mjs | 4 +- scripts/lib/release-metadata.mjs | 134 +++++++++ scripts/lib/source-art-contract.mjs | 4 +- scripts/lib/source-art-reviews.mjs | 87 ++++++ scripts/lib/theme-generator.mjs | 78 +++-- scripts/prepare-desktop.mjs | 2 +- scripts/smoke-theme-manager.mjs | 5 +- scripts/sync-release-metadata.mjs | 275 ++++++++++++++++++ scripts/validate.mjs | 67 +++-- site/assets/app.js | 95 +++++- site/assets/styles.css | 89 ++++++ site/index.html | 5 +- tests/repository.test.mjs | 21 +- tests/source-contract.test.mjs | 94 +++++- themes/catalog.json | 5 - themes/source-art/reviews.json | 67 +++++ 41 files changed, 1986 insertions(+), 127 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/accessibility-report.yml create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/compatibility-report.yml create mode 100644 .github/ISSUE_TEMPLATE/performance-report.yml create mode 100644 docs/release-metadata.md create mode 100644 metadata/release.json create mode 100644 schemas/source-art-review.schema.json create mode 100644 scripts/lib/release-metadata.mjs create mode 100644 scripts/lib/source-art-reviews.mjs create mode 100644 scripts/sync-release-metadata.mjs create mode 100644 themes/source-art/reviews.json diff --git a/.github/ISSUE_TEMPLATE/accessibility-report.yml b/.github/ISSUE_TEMPLATE/accessibility-report.yml new file mode 100644 index 0000000..8451ed5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/accessibility-report.yml @@ -0,0 +1,44 @@ +name: Accessibility report / 无障碍报告 +description: Report a keyboard, focus, contrast, screen-reader, zoom, motion, or forced-colors barrier. +title: "[Accessibility] " +labels: ["accessibility"] +body: + - type: dropdown + id: surface + attributes: + label: Surface / 受影响部分 + options: + - Gallery website + - Theme Manager + - Theme Manager installer or update UI + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment / 环境 + description: OS, browser/WebView, assistive technology when relevant, zoom or display scaling, and color/motion settings. + validations: + required: true + + - type: textarea + id: barrier + attributes: + label: Barrier and expected outcome / 障碍与预期结果 + description: Include the control or content, current behavior, expected behavior, and why it blocks the task. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Reproduction steps / 复现步骤 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe evidence / 脱敏证据 + description: Screenshots, a short recording, or screen-reader output are welcome after private content is removed. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..d015024 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,63 @@ +name: Bug report / 缺陷报告 +description: Report a reproducible Gallery, generator, Theme Manager, or Native fallback defect. +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Do not include credentials, private chats, account names, local home paths, or unredacted screenshots. For a security vulnerability, follow SECURITY.md instead of opening a public issue. + + - type: dropdown + id: surface + attributes: + label: Surface / 受影响部分 + options: + - Gallery + - Theme generator or validator + - Theme Manager + - Full Skin runtime + - Native fallback + - Installer or release artifact + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment / 环境 + description: Include OS, architecture, browser or Theme Manager version, and the exact Codex version only when relevant. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Reproduction steps / 复现步骤 + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: textarea + id: expected_actual + attributes: + label: Expected and actual result / 预期与实际结果 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe evidence / 脱敏证据 + description: Add a redacted error, screenshot, or a minimal trace. Do not attach private application data. + + - type: checkboxes + id: confirmation + attributes: + label: Confirmation / 确认 + options: + - label: I removed private data, secrets, and personal paths from this report. / 我已移除私有数据、密钥和个人路径。 + required: true diff --git a/.github/ISSUE_TEMPLATE/compatibility-report.yml b/.github/ISSUE_TEMPLATE/compatibility-report.yml new file mode 100644 index 0000000..ac8729d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility-report.yml @@ -0,0 +1,55 @@ +name: Compatibility report / 兼容性报告 +description: Report a platform, package-version, or supported-runtime compatibility result. +title: "[Compatibility] " +labels: ["compatibility"] +body: + - type: markdown + attributes: + value: | + Compatibility is version-bound. Please do not run a Full Skin probe against a personal or production session just to fill this form. Remove private paths, user names, chats, and account data. + + - type: dropdown + id: platform + attributes: + label: Platform / 平台 + options: + - Windows x64 + - macOS Apple silicon + - macOS Intel + - Other or unsupported platform + validations: + required: true + + - type: dropdown + id: path + attributes: + label: Feature path / 功能路径 + options: + - Full Skin apply and restore + - Native fallback import + - Persistent replay + - Theme Manager build or launch + - App update channel + validations: + required: true + + - type: textarea + id: identity + attributes: + label: Version and target identity / 版本与目标身份 + description: State exact app and Theme Manager versions. Redact personal installation paths; package family/channel is enough. + validations: + required: true + + - type: textarea + id: result + attributes: + label: Result and verification boundary / 结果与验证边界 + description: Describe what worked or failed, what direct evidence you observed, and any step you intentionally did not run. + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe evidence / 脱敏证据 diff --git a/.github/ISSUE_TEMPLATE/performance-report.yml b/.github/ISSUE_TEMPLATE/performance-report.yml new file mode 100644 index 0000000..de0f8ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/performance-report.yml @@ -0,0 +1,51 @@ +name: Performance report / 性能报告 +description: Report measurable slowness, memory pressure, rendering jank, or generator regressions. +title: "[Performance] " +labels: ["performance"] +body: + - type: markdown + attributes: + value: | + Use privacy-safe measurements. Do not upload full process dumps, private pages, account data, or credentials. + + - type: dropdown + id: surface + attributes: + label: Surface / 受影响部分 + options: + - Gallery load, search, or filter + - Theme generator + - Theme Manager interface + - Full Skin apply or restore + - Persistent replay + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment and build / 环境与构建 + description: OS, architecture, app/browser version, catalog revision, and hardware class. Omit serial numbers and private paths. + validations: + required: true + + - type: textarea + id: measurement + attributes: + label: Measurement / 测量结果 + description: State baseline, observed time or memory, sample count, and how you measured it. + placeholder: "Baseline: ...\nObserved: ...\nSamples: ...\nMethod: ..." + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Reproduction path / 复现路径 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe trace or screenshot / 脱敏追踪或截图 diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index db7f018..be54b2c 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -202,6 +202,11 @@ jobs: fi done + - name: Verify release tag matches metadata + if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' + shell: bash + run: test "${{ github.ref_name }}" = "v0.3.2" + - name: Build updater-signed beta artifacts and publish the tagged release if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' uses: tauri-apps/tauri-action@v1 @@ -214,10 +219,10 @@ jobs: projectPath: apps/theme-manager args: "--config src-tauri/tauri.release.conf.json ${{ matrix.build-args }}" tagName: ${{ github.ref_name }} - releaseName: "Awesome Codex Theme ${{ github.ref_name }} Beta" + releaseName: "Awesome Codex Theme v0.3.2 Beta" releaseAssetNamePattern: ${{ matrix.release-asset-name }} releaseBody: "Updater-signed Tauri Theme Manager beta with ACT Full Skin v1. Windows Authenticode and Apple Developer ID signing/notarization are not included in this beta, so the operating system may show an unknown-publisher warning. macOS bundles carry an ad-hoc signature for package integrity only. The app applies verified backgrounds, materials, colors, and theme copy without modifying ChatGPT application files." - prerelease: false + prerelease: true releaseDraft: true - name: Verify release macOS disk image and ad-hoc signature diff --git a/README.md b/README.md index 835e6af..4826e33 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ The screenshot above comes from ChatGPT Beta `26.715.3651.0` after the skin was > Desktop builds are published only on the official [GitHub Releases page](https://github.com/rwang23/awesome-codex-theme/releases). If that page has no asset compatible with your operating system and CPU, there is no supported public installer for that target. + +## Current platform capability matrix + +This table is generated from [`metadata/release.json`](metadata/release.json) and checked by `npm run metadata:check`. + +| Target | Full Skin | Native fallback | Persistence | +| --- | --- | --- | --- | +| Windows x64 | Verified (`26.715.3651.0`) | Verified (`26.715.2305.0`) | Verified (`26.715.3651.0`) | +| macOS Apple silicon | Build-only | Build-only | Unverified | +| macOS Intel | Build-only | Build-only | Unverified | + +The current catalog has **68 themes** and **136 modes** (revision `2026072201`). +The current `beta` update-feed status is `unreleased`; in-app updates are not claimed until a signed static feed covers every platform. + + ## More than a color palette Awesome Codex Theme combines the parts that make a desktop skin feel complete: @@ -94,7 +109,7 @@ The Windows implementation of **Apply & Keep Full Skin** has passed an exact Cha Theme updates and app updates are independent. The manager refreshes the verified Registry on startup, so newly published themes appear immediately without reinstalling or restarting the app. A signed app update is needed only when the manager itself changes, such as its runtime, compatibility rules, security boundary, platform integration, or interface. -The public beta uses Tauri updater signatures so future app updates can be verified. macOS bundles also carry an ad-hoc signature for package integrity, but Windows Authenticode, Apple Developer ID, and notarization are deferred. The operating system may still show an unknown-publisher warning; neither the updater signature nor the ad-hoc Mac signature removes SmartScreen or Gatekeeper warnings. See [release trust and signing](docs/release-signing.md). +The public beta uses Tauri updater signatures for the future signed-update path. The current update-feed state is intentionally **unreleased**: a GitHub prerelease alone does not enable in-app updates until a signed static feed covers every platform and passes action-after verification. macOS bundles also carry an ad-hoc signature for package integrity, but Windows Authenticode, Apple Developer ID, and notarization are deferred. The operating system may still show an unknown-publisher warning; neither the updater signature nor the ad-hoc Mac signature removes SmartScreen or Gatekeeper warnings. See [release metadata](docs/release-metadata.md) and [release trust and signing](docs/release-signing.md). ## See the real result diff --git a/README.zh-CN.md b/README.zh-CN.md index 8236dec..e8dfc35 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,6 +29,21 @@ > 桌面安装包只会发布在官方 [GitHub Releases 页面](https://github.com/rwang23/awesome-codex-theme/releases)。如果页面上没有与你的操作系统和 CPU 匹配的文件,就表示当前没有支持该目标的公开安装包。 + +## 当前平台能力矩阵 + +此表由 [`metadata/release.json`](metadata/release.json) 生成,并由 `npm run metadata:check` 校验。 + +| 目标 | Full Skin | Native 回退 | 常驻 | +| --- | --- | --- | --- | +| Windows x64 | 已验证 (`26.715.3651.0`) | 已验证 (`26.715.2305.0`) | 已验证 (`26.715.3651.0`) | +| macOS Apple 芯片 | 仅构建 | 仅构建 | 未验证 | +| macOS Intel | 仅构建 | 仅构建 | 未验证 | + +当前 Catalog 有 **68 套主题**、**136 个模式**(修订 `2026072201`)。 +当前 `beta` 通道更新源状态为 `unreleased`;在签名静态清单覆盖全部平台前,不宣称应用内更新可用。 + + ## 它不只是换一组颜色 Awesome Codex Theme 会把一套皮肤需要的部分一起应用: @@ -93,7 +108,7 @@ Windows 版“应用并保持完整皮肤”已经通过 ChatGPT Beta `26.715.36 主题更新与应用更新彼此独立。管理器启动时会刷新经过验证的 Registry,所以新发布的主题无需重装或重启应用就会直接出现。只有管理器自身的运行时、兼容规则、安全边界、平台集成或界面发生变化时,才需要安装签名应用更新。 -公开 Beta 使用 Tauri updater 签名来验证以后收到的应用更新;macOS 应用包还带有只用于包完整性的 ad-hoc 签名。Windows Authenticode、Apple Developer ID 与公证暂时延后,所以系统仍可能显示未知发布者提示。Updater 与 ad-hoc 签名都不能消除 SmartScreen 或 Gatekeeper 提示,详见[发布信任与签名](docs/release-signing.md)。 +公开 Beta 为未来的签名更新链路保留 Tauri updater 签名。当前更新源状态明确为**尚未发布**:仅有 GitHub prerelease 并不代表可以应用内更新,必须先有覆盖全部平台的签名静态清单并完成 action-after 验证。macOS 应用包还带有只用于包完整性的 ad-hoc 签名。Windows Authenticode、Apple Developer ID 与公证暂时延后,所以系统仍可能显示未知发布者提示。Updater 与 ad-hoc 签名都不能消除 SmartScreen 或 Gatekeeper 提示,详见[发布元数据](docs/release-metadata.md)与[发布信任与签名](docs/release-signing.md)。 ## 真实使用效果 diff --git a/apps/theme-manager/src-tauri/src/catalog.rs b/apps/theme-manager/src-tauri/src/catalog.rs index 6beb589..b61cb29 100644 --- a/apps/theme-manager/src-tauri/src/catalog.rs +++ b/apps/theme-manager/src-tauri/src/catalog.rs @@ -17,6 +17,15 @@ const REMOTE_REGISTRY_URL: &str = "https://rwang23.github.io/awesome-codex-theme/themes/registry.json"; const MODES: [&str; 2] = ["light", "dark"]; +#[derive(Clone)] +struct ReleaseContract { + full_skin_format: String, + native_tested_version: String, + capture_version: String, + capture_package: String, + capture_fixture: String, +} + #[derive(Clone)] pub struct Catalog { pub source: String, @@ -163,6 +172,158 @@ fn valid_hex_color(value: &str) -> bool { .all(|character| character.is_ascii_hexdigit()) } +fn required_string(value: &Value, label: &str) -> Result { + value + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("{label} 必须是非空字符串")) +} + +fn validate_release(registry: &Value) -> Result { + let release = object(®istry["release"], "release")?; + let product = object( + release.get("product").unwrap_or(&Value::Null), + "release.product", + )?; + for field in ["name", "repository", "releases"] { + required_string( + product.get(field).unwrap_or(&Value::Null), + &format!("release.product.{field}"), + )?; + } + let desktop = object( + release.get("desktop").unwrap_or(&Value::Null), + "release.desktop", + )?; + required_string( + desktop.get("version").unwrap_or(&Value::Null), + "release.desktop.version", + )?; + if !matches!( + desktop.get("channel").and_then(Value::as_str), + Some("beta" | "stable") + ) { + return fail("release.desktop.channel 无效"); + } + let updater = object( + desktop.get("updater").unwrap_or(&Value::Null), + "release.desktop.updater", + )?; + if !matches!( + updater.get("status").and_then(Value::as_str), + Some("unreleased" | "available") + ) { + return fail("release.desktop.updater.status 无效"); + } + localized( + updater.get("message").unwrap_or(&Value::Null), + "release.desktop.updater.message", + )?; + let runtime = object( + release.get("runtime").unwrap_or(&Value::Null), + "release.runtime", + )?; + let full_skin_format = required_string( + runtime.get("fullSkinFormat").unwrap_or(&Value::Null), + "release.runtime.fullSkinFormat", + )?; + if full_skin_format != "act-full-skin-v1" { + return fail("release.runtime.fullSkinFormat 不受支持"); + } + required_string( + runtime.get("implementationVersion").unwrap_or(&Value::Null), + "release.runtime.implementationVersion", + )?; + required_string( + runtime.get("sourceArtRenderer").unwrap_or(&Value::Null), + "release.runtime.sourceArtRenderer", + )?; + let native_tested_version = required_string( + runtime.get("nativeTestedVersion").unwrap_or(&Value::Null), + "release.runtime.nativeTestedVersion", + )?; + let capture = object( + release.get("capture").unwrap_or(&Value::Null), + "release.capture", + )?; + if capture.get("channel").and_then(Value::as_str) != Some("beta") { + return fail("release.capture.channel 必须是 beta"); + } + let capture_version = required_string( + capture.get("appVersion").unwrap_or(&Value::Null), + "release.capture.appVersion", + )?; + let capture_package = required_string( + capture.get("packageFullName").unwrap_or(&Value::Null), + "release.capture.packageFullName", + )?; + if !capture_package.starts_with("OpenAI.CodexBeta_") { + return fail("release.capture.packageFullName 不是 Codex Beta 包"); + } + let capture_fixture = required_string( + capture.get("fixture").unwrap_or(&Value::Null), + "release.capture.fixture", + )?; + required_string( + capture.get("modelLabel").unwrap_or(&Value::Null), + "release.capture.modelLabel", + )?; + Ok(ReleaseContract { + full_skin_format, + native_tested_version, + capture_version, + capture_package, + capture_fixture, + }) +} + +fn validate_capabilities(value: &Value) -> Result<(), String> { + let capabilities = array(value, "capabilities")?; + if capabilities.is_empty() || capabilities.len() > 16 { + return fail("capabilities 数量超出边界"); + } + let mut ids = HashSet::new(); + for capability in capabilities { + let id = capability["id"].as_str().unwrap_or_default(); + if !safe_id(id) || !ids.insert(id.to_owned()) { + return fail("capability ID 无效或重复"); + } + if !matches!(capability["platform"].as_str(), Some("windows" | "macos")) + || !matches!( + capability["architecture"].as_str(), + Some("x64" | "aarch64" | "x86_64") + ) + { + return fail(format!("{id} capability 平台或架构无效")); + } + localized(&capability["title"], &format!("{id} capability title"))?; + for feature_name in ["fullSkin", "native", "persistence"] { + let feature = object(&capability[feature_name], &format!("{id} {feature_name}"))?; + if !matches!( + feature.get("status").and_then(Value::as_str), + Some("verified" | "build-only" | "unverified") + ) { + return fail(format!("{id} {feature_name} status 无效")); + } + feature + .get("testedVersion") + .and_then(Value::as_str) + .ok_or_else(|| format!("{id} {feature_name} testedVersion 无效"))?; + localized( + feature.get("detail").unwrap_or(&Value::Null), + &format!("{id} {feature_name} detail"), + )?; + } + } + for id in ["windows-x64", "macos-aarch64", "macos-x86-64"] { + if !ids.contains(id) { + return fail(format!("capabilities 缺少 {id}")); + } + } + Ok(()) +} + fn validate_native_theme(value: &str, mode: &str) -> Result<(), String> { let body = value .strip_prefix("codex-theme-v1:") @@ -227,6 +388,7 @@ fn validate_mode( theme: &Value, mode: &str, native_values: &mut HashSet, + release: &ReleaseContract, ) -> Result<(), String> { let record = theme["previews"] .get(mode) @@ -243,31 +405,26 @@ fn validate_mode( || !capture["sha256"].as_str().is_some_and(valid_sha256) || !capture["assetSha256"].as_str().is_some_and(valid_sha256) || !capture["runtimeSha256"].as_str().is_some_and(valid_sha256) - || capture["markerVersion"].as_str() != Some("act-full-skin-v1") + || capture["markerVersion"].as_str() != Some(release.full_skin_format.as_str()) || capture["width"].as_u64() != Some(1440) || capture["height"].as_u64() != Some(810) - || capture["fixture"].as_str() != Some("full-skin-home-v1") - || capture["appVersion"] - .as_str() - .unwrap_or_default() - .is_empty() + || capture["appVersion"].as_str() != Some(release.capture_version.as_str()) + || capture["packageFullName"].as_str() != Some(release.capture_package.as_str()) + || capture["fixture"].as_str() != Some(release.capture_fixture.as_str()) { return fail(format!("{theme_id} {mode} 实机截图记录无效")); } let full_skin = &record["fullSkin"]; let full_skin_asset = full_skin["asset"].as_str().unwrap_or_default(); - if full_skin["format"].as_str() != Some("act-full-skin-v1") + if full_skin["format"].as_str() != Some(release.full_skin_format.as_str()) || !safe_relative_path(full_skin_asset) || full_skin_asset != format!("themes/{theme_id}/assets/background-{mode}.png") || !full_skin["sha256"].as_str().is_some_and(valid_sha256) || full_skin["sha256"] != record["assetSha256"] || full_skin["bytes"].as_u64().is_none() || full_skin["bytes"] != record["assetBytes"] - || full_skin["testedVersion"] - .as_str() - .unwrap_or_default() - .is_empty() + || full_skin["testedVersion"].as_str() != Some(release.capture_version.as_str()) { return fail(format!("{theme_id} {mode} Full Skin 记录无效")); } @@ -303,6 +460,7 @@ fn validate_mode( if native["format"].as_str() != Some("codex-theme-v1") || !safe_relative_path(native_path) || !native["sha256"].as_str().is_some_and(valid_sha256) + || native["testedVersion"].as_str() != Some(release.native_tested_version.as_str()) { return fail(format!("{theme_id} {mode} Native 记录无效")); } @@ -332,6 +490,8 @@ pub fn validate_registry(bytes: &[u8]) -> Result { return fail("Registry 标准不受支持"); } + let release = validate_release(®istry)?; + validate_capabilities(®istry["capabilities"])?; let collections = array(®istry["collections"], "collections")?; let themes = array(®istry["themes"], "themes")?; if collections.is_empty() || themes.is_empty() || themes.len() > 500 { @@ -367,7 +527,7 @@ pub fn validate_registry(bytes: &[u8]) -> Result { return fail(format!("{id} 权利标记无效")); } for mode in MODES { - validate_mode(id, theme, mode, &mut native_values)?; + validate_mode(id, theme, mode, &mut native_values, &release)?; } } Ok(registry) @@ -500,6 +660,11 @@ pub async fn load_remote() -> Result<(Catalog, String), String> { if registry["themes"].as_array().map(Vec::len) != Some(expected_themes) { return fail("远程 Registry 主题数量不匹配"); } + if manifest["release"] != registry["release"] + || manifest["capabilities"] != registry["capabilities"] + { + return fail("远程 Registry 发布元数据不匹配"); + } let registry_text = String::from_utf8(registry_bytes.clone()) .map_err(|error| format!("远程 Registry 不是 UTF-8:{error}"))?; Ok(( @@ -583,6 +748,8 @@ pub fn present_catalog(app: &AppHandle, catalog: &Catalog) -> Result ( + localized(capability.title) + " " + capabilityStatusLabel(capability.fullSkin?.status) + )).join(" · "); +} + function renderAppVersion() { elements.appVersion.textContent = state.appVersion ? `v${state.appVersion}` : "—"; } @@ -780,6 +807,7 @@ function renderLanguage() { if (state.catalog) { normalizeCollectionSelection(); renderCatalogStatus(); + renderCapabilitySummary(); renderStyleTabs(); renderCollectionTabs(); renderFacetOptions(); @@ -806,6 +834,7 @@ function acceptCatalog(payload) { : ordered[0]?.id; normalizeCollectionSelection(); renderCatalogStatus(); + renderCapabilitySummary(); renderStyleTabs(); renderCollectionTabs(); renderFacetOptions(); diff --git a/apps/theme-manager/src/renderer/index.html b/apps/theme-manager/src/renderer/index.html index 7001fdb..d3b0a3f 100644 --- a/apps/theme-manager/src/renderer/index.html +++ b/apps/theme-manager/src/renderer/index.html @@ -182,6 +182,7 @@

+
- 53 套主题 +
@@ -300,6 +300,7 @@

完整皮肤是主交 复制后手动导入 +
diff --git a/tests/repository.test.mjs b/tests/repository.test.mjs index c3ecb5e..355b377 100644 --- a/tests/repository.test.mjs +++ b/tests/repository.test.mjs @@ -398,7 +398,8 @@ test("Tauri manager keeps theme values in Rust and limits desktop capabilities", assert.match(bridge, /applyNow = false/); assert.match(bridge, /applyNow \}/); assert.match(updater, /ACT_UPDATER_PUBKEY/); - assert.match(config.plugins.updater.endpoints[0], /^https:\/\/github\.com\/rwang23\/awesome-codex-theme\/releases\//); + assert.equal(config.plugins.updater.endpoints[0], "https://rwang23.github.io/awesome-codex-theme/updates/stable.json"); + assert.doesNotMatch(config.plugins.updater.endpoints[0], /github\.com\/.*\/releases\/(?:latest|download)/); assert.match(appPackage, /@tauri-apps\/cli/); assert.doesNotMatch(appPackage, /electron/i); assert.doesNotMatch(backend + catalog + runtime + platform, /WindowsApps.*(?:write|copy)|app\.asar.*(?:write|copy)/i); @@ -582,12 +583,16 @@ test("Tibo copy uses a deliberate two-line God of Reset lockup", async function assert.deepEqual(immortal.tagline, { en: "God of Reset", "zh-CN": "重置之神" }); }); -test("desktop beta release requires updater signing and discloses deferred platform trust", async function () { - const [workflow, tauriConfigText] = await Promise.all([ +test("desktop beta release uses a static updater feed and discloses deferred platform trust", async function () { + const [workflow, tauriConfigText, releaseMetadata] = await Promise.all([ readFile(path.join(ROOT, ".github", "workflows", "desktop.yml"), "utf8"), - readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "tauri.conf.json"), "utf8") + readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "tauri.conf.json"), "utf8"), + readFile(path.join(ROOT, "metadata", "release.json"), "utf8").then(JSON.parse), ]); const tauriConfig = JSON.parse(tauriConfigText); + const releaseLabel = releaseMetadata.desktop.channel.slice(0, 1).toUpperCase() + + releaseMetadata.desktop.channel.slice(1); + const versionPattern = releaseMetadata.desktop.version.replaceAll(".", "\\."); assert.match(workflow, /npm run generate && npm run validate && npm run desktop:prepare/); assert.match(workflow, /DESKTOP_RELEASE_READY/); assert.match(workflow, /TAURI_SIGNING_PRIVATE_KEY/); @@ -596,9 +601,11 @@ test("desktop beta release requires updater signing and discloses deferred platf assert.match(workflow, /Validate Windows portable helper/); assert.match(workflow, /npm run installer:validate/); assert.match(workflow, /Updater-signed Tauri Theme Manager beta/); - assert.match(workflow, /releaseName: "Awesome Codex Theme \$\{\{ github\.ref_name \}\} Beta"/); + assert.match(workflow, new RegExp('releaseName: "Awesome Codex Theme v' + versionPattern + " " + releaseLabel + '"')); + assert.match(workflow, /Verify release tag matches metadata/); + assert.match(workflow, new RegExp('run: test "\\$\\{\\{ github\\.ref_name \\}\\}" = "v' + versionPattern + '"')); assert.match(workflow, /unknown-publisher warning/); - assert.match(workflow, /prerelease: false/); + assert.match(workflow, new RegExp("prerelease: " + releaseMetadata.desktop.githubPrerelease)); assert.match(workflow, /releaseDraft: true/); assert.doesNotMatch(workflow, /WINDOWS_CERTIFICATE/); assert.doesNotMatch(workflow, /APPLE_CERTIFICATE/); @@ -617,6 +624,8 @@ test("desktop beta release requires updater signing and discloses deferred platf assert.doesNotMatch(workflow, /desktop-macos-(?:arm64|x64)/); assert.equal(tauriConfig.bundle.macOS.signingIdentity, "-"); assert.match(tauriConfig.plugins.updater.pubkey, /^[A-Za-z0-9+/=]{100,}$/); + assert.equal(tauriConfig.plugins.updater.endpoints[0], releaseMetadata.desktop.updater.endpoint); + assert.doesNotMatch(tauriConfig.plugins.updater.endpoints[0], /github\.com\/.*\/releases\/(?:latest|download)/); }); test("desktop preparation derives compact thumbnails from verified captures", async function () { diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index 85cdac8..9e77481 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -16,20 +16,28 @@ import { CODEX_THEME_PREFIX, parseCodexNativeTheme, } from "../scripts/lib/codex-native-theme.mjs"; +import { readReleaseMetadata, releaseForRegistry } from "../scripts/lib/release-metadata.mjs"; import { renderFingerprint, SOURCE_ART_RENDERER_CONSTANTS, } from "../scripts/lib/source-art-contract.mjs"; +import { + resolveSourceArtReview, + validateSourceArtReviewLedger, +} from "../scripts/lib/source-art-reviews.mjs"; +import { syncReleaseMetadata } from "../scripts/sync-release-metadata.mjs"; import { contrastRatio, isSafeRelativePath } from "../scripts/validate.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); test("source catalog and image-job provenance stay aligned without generated artifacts", async function () { - const [catalog, jobs, themeSchema, registrySchema] = await Promise.all([ + const [catalog, jobs, themeSchema, registrySchema, reviewSchema, reviews] = await Promise.all([ readFile(path.join(ROOT, "themes", "catalog.json"), "utf8").then(JSON.parse), readFile(path.join(ROOT, "themes", "source-art", "jobs.json"), "utf8").then(JSON.parse), readFile(path.join(ROOT, "schemas", "theme-pack.schema.json"), "utf8").then(JSON.parse), readFile(path.join(ROOT, "schemas", "registry.schema.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "schemas", "source-art-review.schema.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "source-art", "reviews.json"), "utf8").then(JSON.parse), ]); assert.equal(catalog.catalogRevision, 2026072201); @@ -37,6 +45,7 @@ test("source catalog and image-job provenance stay aligned without generated art assert.equal(catalog.collections.length, 11); assert.equal(themeSchema.$schema.includes("2020-12"), true); assert.equal(registrySchema.$schema.includes("2020-12"), true); + assert.equal(reviewSchema.$schema.includes("2020-12"), true); assert.deepEqual( jobs.jobs.map((job) => job.id).sort(), catalog.themes.map((theme) => theme.id).sort(), @@ -53,6 +62,88 @@ test("source catalog and image-job provenance stay aligned without generated art assert.equal(theme.fanArt.officialAssetsUsed, false); } } + assert.equal(validateSourceArtReviewLedger(catalog, reviews).records.size, 49); +}); + +test("source-art approval is explicit, source-hash-bound, and excludes fan art", async function () { + const [catalog, reviews] = await Promise.all([ + readFile(path.join(ROOT, "themes", "catalog.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "source-art", "reviews.json"), "utf8").then(JSON.parse), + ]); + const parsed = validateSourceArtReviewLedger(catalog, reviews); + const original = catalog.themes.find((theme) => (theme.rightsProfile || "original") === "original"); + const fanArt = catalog.themes.find((theme) => theme.rightsProfile === "fan-art"); + const provenance = JSON.parse(await readFile( + path.join(ROOT, "themes", "source-art", original.id + ".provenance.json"), + "utf8", + )); + const review = resolveSourceArtReview(original, provenance, parsed); + assert.equal(review.decision, "approved"); + assert.equal(review.sourceSha256, provenance.sourceSha256); + assert.equal(resolveSourceArtReview(fanArt, {}, parsed), null); + assert.throws( + () => resolveSourceArtReview(original, { ...provenance, sourceSha256: "0".repeat(64) }, parsed), + /approval source hash does not match provenance/, + ); +}); + +test("release metadata is the synchronized source for desktop version and capability disclosure", async function () { + const metadata = readReleaseMetadata(ROOT); + const [rootPackage, managerPackage, tauriConfig, catalog, capture, smoke, skinRuntime, readmeText, chineseReadmeText] = await Promise.all([ + readFile(path.join(ROOT, "package.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "apps", "theme-manager", "package.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "tauri.conf.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "catalog.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "scripts", "capture-full-skin-screenshots.mjs"), "utf8"), + readFile(path.join(ROOT, "scripts", "smoke-theme-manager.mjs"), "utf8"), + readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "src", "skin_runtime.rs"), "utf8"), + readFile(path.join(ROOT, "README.md"), "utf8"), + readFile(path.join(ROOT, "README.zh-CN.md"), "utf8"), + ]); + assert.equal(rootPackage.version, metadata.desktop.version); + assert.equal(managerPackage.version, metadata.desktop.version); + assert.equal(tauriConfig.version, metadata.desktop.version); + assert.deepEqual(tauriConfig.plugins.updater.endpoints, [metadata.desktop.updater.endpoint]); + assert.doesNotMatch(tauriConfig.plugins.updater.endpoints[0], /releases\/latest/); + assert.equal(metadata.desktop.githubPrerelease, true); + assert.equal(metadata.capabilities.length, 3); + assert.equal(catalog.defaults, undefined); + assert.deepEqual(releaseForRegistry(metadata).capture, { + channel: metadata.capture.channel, + appVersion: metadata.capture.appVersion, + packageFullName: metadata.capture.packageFullName, + fixture: metadata.capture.fixture, + modelLabel: metadata.capture.modelLabel, + }); + assert.match(capture, /readReleaseMetadata/); + assert.match(smoke, /readReleaseMetadata/); + assert.doesNotMatch(capture, /const CAPTURE_VERSION = "\d+\.\d+\.\d+"/); + assert.doesNotMatch(smoke, /const BETA_PACKAGE = "OpenAI\.CodexBeta_/); + assert.match(skinRuntime, new RegExp(metadata.runtime.implementationVersion)); + for (const currentReadme of [readmeText, chineseReadmeText]) { + assert.match(currentReadme, //); + assert.match(currentReadme, //); + } + await assert.doesNotReject(syncReleaseMetadata(["--check"])); +}); + +test("Gallery and compatibility docs consume generated metadata instead of stale fallback claims", async function () { + const [index, gallery, adapters, build] = await Promise.all([ + readFile(path.join(ROOT, "site", "index.html"), "utf8"), + readFile(path.join(ROOT, "site", "assets", "app.js"), "utf8"), + readFile(path.join(ROOT, "docs", "adapters.md"), "utf8"), + readFile(path.join(ROOT, "scripts", "build.mjs"), "utf8"), + ]); + assert.match(index, /id="themeStatValue">—/); + assert.match(index, /id="resultSummary">—/); + assert.match(index, /id="capabilityMatrix"/); + assert.match(gallery, /renderCapabilityMatrix/); + assert.match(gallery, /state\.registry\?\.themes\?\.length/); + assert.doesNotMatch(gallery, /all 106 theme modes/); + assert.match(adapters, /Blob URL/); + assert.match(adapters, /--act-art-image/); + assert.doesNotMatch(adapters, /assigns that verified image to a manager-created/); + assert.match(build, /registry\.release\?\.desktop\?\.version/); }); test("generated-artifact guard describes the missing prerequisite", async function () { @@ -117,6 +208,7 @@ test("package scripts keep source checks fast and generated checks explicit", as assert.match(packageJson.scripts["test:source"], /source-contract\.test\.mjs/); assert.match(packageJson.scripts["test:generated"], /ensure-generated\.mjs/); assert.match(packageJson.scripts["test:ci"], /^npm run generate/); + assert.equal(packageJson.scripts["metadata:check"], "node scripts/sync-release-metadata.mjs --check"); assert.match(packageJson.scripts["check:full"], /npm run test:ci/); }); diff --git a/themes/catalog.json b/themes/catalog.json index 559ee94..2492551 100644 --- a/themes/catalog.json +++ b/themes/catalog.json @@ -1,11 +1,6 @@ { "schemaVersion": 1, "catalogRevision": 2026072201, - "defaults": { - "author": "Awesome Codex Theme", - "license": "CC0-1.0", - "codexDesktopTested": "26.715.2305.0" - }, "collections": [ { "id": "original-xianxia-01", diff --git a/themes/source-art/reviews.json b/themes/source-art/reviews.json new file mode 100644 index 0000000..09f91ec --- /dev/null +++ b/themes/source-art/reviews.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "review": { + "reviewer": "Awesome Codex Theme source-art review", + "reviewedAt": "2026-07-25T00:00:00Z", + "checks": { + "noThirdPartyLogo": true, + "noSignatureOrWatermark": true, + "noUnintendedText": true, + "rightsProfileMatches": true, + "safeAreaReviewed": true, + "lightDarkCaptureReviewed": true + }, + "notes": "Structured migration of the existing source-specific provenance review disclosure and the 2026-07-25 audit corpus evidence. This ledger does not create a claim of third-party rights clearance." + }, + "attestations": [ + { "themeId": "qinglan-odyssey", "sourceSha256": "a4aa321572bc6e0f7c0499ef5bee9604bec8ecb0f6118700ee6fb1140f390fad", "decision": "approved" }, + { "themeId": "qinglan-sprite", "sourceSha256": "24914a9023a0ed9fbc0e91609b601ff28b1fb5b8e356cd9e362caa8c8b3dca25", "decision": "approved" }, + { "themeId": "starbound-rebel", "sourceSha256": "dd68bf22d055224eb2c7f6b2acc17cdd7547d44d8480d0907db4deb15481cf6b", "decision": "approved" }, + { "themeId": "starbound-sprite", "sourceSha256": "8b068fbe714747114e894384d38f99ea7e48988c1c2fd0452dcf27e89cd7e661", "decision": "approved" }, + { "themeId": "mountain-sword", "sourceSha256": "8ebeaecf4cf56d5815f5848a0a3d29f8fac47cf2e50cf92ff1159609f92105bc", "decision": "approved" }, + { "themeId": "mountain-swordling", "sourceSha256": "4c1aa94fc4d4c882134a7d8e560b1da454cd6b18066880174d39bdba58c37243", "decision": "approved" }, + { "themeId": "cloudsea-pact", "sourceSha256": "dc7c3fc5de6d8b8b0a4651664f5eb56b9521e8d05c00c7cbc1c07539d9eb42c9", "decision": "approved" }, + { "themeId": "cloudsea-mochi", "sourceSha256": "f5ef9da3beb95c737147f933e3466be16e5708568fa20be1013eb33ca413bac1", "decision": "approved" }, + { "themeId": "beijing-meridian", "sourceSha256": "b8142726fac12d7367e6e3ee4cc99da83ede3bdba954fd158564717131e4f5b4", "decision": "approved" }, + { "themeId": "shanghai-afterglow", "sourceSha256": "fd0d2b8ff411bc53930590c97f102b14b3720f7a5382af0a87db0fc101b7f15b", "decision": "approved" }, + { "themeId": "shenzhen-circuit-rain", "sourceSha256": "fe02957766cbcbae22966e86cd93e9dec90e00ba3af79c00bdc889ff4c3bebbe", "decision": "approved" }, + { "themeId": "guangzhou-riverwind", "sourceSha256": "14eb4ec1e73004c2c838fa7e4fefa8e9495ced908a1168516b71e500c76e2395", "decision": "approved" }, + { "themeId": "chengdu-teahouse-rain", "sourceSha256": "4f28fe473e04ba709faab35061f49b04ff1cfa4abf72ea4380eceb50bb26a484", "decision": "approved" }, + { "themeId": "hangzhou-lake-letter", "sourceSha256": "0ae96df48939d86fe28ef67c236fc08904e13bbc29bd5858c1ffb329a5f4e013", "decision": "approved" }, + { "themeId": "chongqing-vertical-night", "sourceSha256": "e96d38ec1d79a1c59a41396445be0c4e46c634f7aac2e77d40dca3104fe65475", "decision": "approved" }, + { "themeId": "nanjing-wall-plum", "sourceSha256": "813706847e3d95e722b3d31748a1c6b9a0b14481f72fbe496e411f70f89a5517", "decision": "approved" }, + { "themeId": "new-york-rainline", "sourceSha256": "088f2a7d1bb1c174074903a93c5dc8ddef61ecb14ce66470b1267f6aae6ef76b", "decision": "approved" }, + { "themeId": "san-francisco-fogline", "sourceSha256": "a0aa639ab2de1edb04a87b817589c7b5ad6bab74619116063a742dd9e4c88a08", "decision": "approved" }, + { "themeId": "chicago-lakewind", "sourceSha256": "ead77a3bb826d123eb5622021eddaa2f434a1f1bc4e8ad58c3772de732b18853", "decision": "approved" }, + { "themeId": "toronto-blue-hour", "sourceSha256": "430d32e58f85f92c90f60508860bc401e8ae67fd659194b32a79cacf35ad8136", "decision": "approved" }, + { "themeId": "vancouver-rain-harbour", "sourceSha256": "cef1bc1b5a7d5d73827e6668bfea27074e1db3ca3241a998f5b440990630927d", "decision": "approved" }, + { "themeId": "london-rainlight", "sourceSha256": "4b6fec54dd3dcef09977b1b30e5a10d35d4c8b0fb1b1cc7fc50dd4afac03c001", "decision": "approved" }, + { "themeId": "paris-rooftop-dawn", "sourceSha256": "0e88d6e78ebd37089616560861fca879198372063056e1ca9bf337528e36cccb", "decision": "approved" }, + { "themeId": "berlin-night-grid", "sourceSha256": "e2ba597974ae373f18747ed2981471af70d3aba206b2455c2bd74115d8bb993a", "decision": "approved" }, + { "themeId": "rome-terrace-gold", "sourceSha256": "d08bdc0c4afcad2a937caf72a28ae2f4eb06bb1582ecf907f6f618d61f3c1e15", "decision": "approved" }, + { "themeId": "tokyo-rain-signal", "sourceSha256": "05e9dd74fab41b653ba092c16db8fda2db1c139879f3300e4045c0d892030e70", "decision": "approved" }, + { "themeId": "singapore-garden-rain", "sourceSha256": "2b011ccb907494d14c11ca863aa440a32a122eaac650f5f6de0413e9b226616a", "decision": "approved" }, + { "themeId": "sydney-harbour-morning", "sourceSha256": "7088abbefd7b298722cf1f7d619cd8322d4082323a7967a442b840eada2cb9b4", "decision": "approved" }, + { "themeId": "retro-boss-2007", "sourceSha256": "8eb319040e9ea259222f086fc9d6efa2e910e66020c719e809e597bffb007207", "decision": "approved" }, + { "themeId": "rainy-library", "sourceSha256": "8fef3ada2a2a68e5b1e4b15bf286714f0368db0d1f2ced87a683a78c05f5f499", "decision": "approved" }, + { "themeId": "orbital-dawn", "sourceSha256": "24a646821c0b99903c4c3902d0aff750fe2a38414e9ce5785f6d23611338c77e", "decision": "approved" }, + { "themeId": "deep-ocean-lab", "sourceSha256": "04e7e503c417998108213dbca3e77778e08c024e351eab6d26e4f52d0df9f7de", "decision": "approved" }, + { "themeId": "synthwave-night-drive", "sourceSha256": "4bd9a5625708adf0b9b33ecb4bf35fc1ea702f0087e2971aadffdc5b35c42a12", "decision": "approved" }, + { "themeId": "alpine-cabin-code", "sourceSha256": "3059992ca351345e0b2e484d57e077bcc7695a10ab956ec395dce0b650aa391c", "decision": "approved" }, + { "themeId": "jiangnan-rain-courtyard", "sourceSha256": "4f927db5183332d694185075714c6e52c9093e493e38369d5925d59a8ab7a6a1", "decision": "approved" }, + { "themeId": "changan-lanterns", "sourceSha256": "07871b73b171ddccc9dae7cd5e200263ef9f775d198be7684c932ffa3a360900", "decision": "approved" }, + { "themeId": "dunhuang-starlight", "sourceSha256": "62d5e1fdbdd6c5c0c739a7221086294d63a49d3e10b02b0a6c1c99e09ac2ed72", "decision": "approved" }, + { "themeId": "bamboo-tea-rain", "sourceSha256": "11d98c363d0302954874a7f01231075a4036c602dac4919b8873ce95e98017e7", "decision": "approved" }, + { "themeId": "northeast-snow-train", "sourceSha256": "d1b90373be709a954cc0136f4fb66a9f81e1baef2c5f14ea1f0fb877be6241a8", "decision": "approved" }, + { "themeId": "appalachian-cabin-focus", "sourceSha256": "9d586d9717c74cbb0a780763f5fefce788fc0f70838d2550f1c7ac0de7cf2b76", "decision": "approved" }, + { "themeId": "cloud-forge-quiet", "sourceSha256": "152833b53eaf14c870ad6df5a60bc9dd3ebfb3504b9e48af26e1d0ff62ab8181", "decision": "approved" }, + { "themeId": "coastal-studio-morning", "sourceSha256": "60a7797c7ca393e3b27a5da7a52b9c3f61c94358b47ae718ea1e75f699b0c1d7", "decision": "approved" }, + { "themeId": "desert-observatory-night", "sourceSha256": "5306564b8a931e00a971a1d6700c9d0a62c40e702fed4f93d91f013c43ed7383", "decision": "approved" }, + { "themeId": "firefly-river-pact", "sourceSha256": "19a1e78781c570425fc28ad915847120dbdc616a998153a215e4bb84fb78f490", "decision": "approved" }, + { "themeId": "great-lakes-rainroom", "sourceSha256": "9b3596a8b21e657ff402692150114c684be4175d22e83c2061c2bca8bc0a1d48", "decision": "approved" }, + { "themeId": "jade-archive-dawn", "sourceSha256": "e1cd53e9a1952adda1d761b2b7f64c03c420223e56650350d0622d03ca9afb58", "decision": "approved" }, + { "themeId": "late-diner-afterhours", "sourceSha256": "39a525aeca2f7fa5d5ecfeec798f6203e16222a8d80fa08cbc0147cac7923860", "decision": "approved" }, + { "themeId": "plum-sword-snow", "sourceSha256": "0db74a9df358991d4261236a9ad94a31a963e187b75ea2c6693c2d1e01015a1a", "decision": "approved" }, + { "themeId": "sky-garden-herbarium", "sourceSha256": "69fd6fdfaafe97821391859e5d07a2a160ceba6f06f3ff0f1dd1d62f47babeba", "decision": "approved" } + ] +}