From 72b256cab10921479d7f6079a667849af81dc7a1 Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 11:56:08 -0400 Subject: [PATCH 1/6] ci: reuse generated artifacts and harden workflows --- .github/dependabot.yml | 20 ++ .github/workflows/desktop.yml | 241 ++++++++++++++++++----- .github/workflows/pages.yml | 66 ------- .github/workflows/supply-chain.yml | 58 ++++++ docs/CHANGELOG.md | 4 + package.json | 1 + scripts/verify-gallery.mjs | 300 +++++++++++++++++++++++++++++ tests/repository.test.mjs | 4 +- tests/source-contract.test.mjs | 33 +++- 9 files changed, 611 insertions(+), 116 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/supply-chain.yml create mode 100644 scripts/verify-gallery.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f255af2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: npm + directory: /apps/theme-manager + schedule: + interval: weekly + + - package-ecosystem: cargo + directory: /apps/theme-manager/src-tauri + schedule: + interval: weekly diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index db7f018..0cd3f85 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -1,4 +1,4 @@ -name: Build desktop theme manager +name: Verify theme artifacts, build Theme Manager, and publish gallery on: push: @@ -7,54 +7,94 @@ on: tags: - "v*" paths: - - ".github/workflows/desktop.yml" + - ".github/dependabot.yml" + - ".github/workflows/**" - "apps/theme-manager/**" - - "packages/full-skin/**" - - "scripts/prepare-desktop.mjs" - - "scripts/capture-full-skin-screenshots.mjs" - - "scripts/smoke-theme-manager.mjs" - - "scripts/lib/png.mjs" - - "themes/catalog.json" - - "themes/source-art/**" - - "screenshots/**" + - "assets/**" + - "docs/assets/**" + - "index.html" + - "installer/**" + - "LICENSE" + - "NOTICE.md" - "package.json" + - "packages/**" + - "schemas/**" + - "scripts/**" + - "screenshots/**" + - "site/**" + - "tests/**" + - "themes/**" pull_request: paths: - - ".github/workflows/desktop.yml" + - ".github/dependabot.yml" + - ".github/workflows/**" - "apps/theme-manager/**" - - "packages/full-skin/**" - - "scripts/prepare-desktop.mjs" - - "scripts/capture-full-skin-screenshots.mjs" - - "scripts/smoke-theme-manager.mjs" - - "scripts/lib/png.mjs" - - "themes/catalog.json" - - "themes/source-art/**" - - "screenshots/**" + - "assets/**" + - "docs/assets/**" + - "index.html" + - "installer/**" + - "LICENSE" + - "NOTICE.md" - "package.json" + - "packages/**" + - "schemas/**" + - "scripts/**" + - "screenshots/**" + - "site/**" + - "tests/**" + - "themes/**" workflow_dispatch: permissions: contents: read +concurrency: + group: theme-artifacts-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - validate: + prepare: + name: Generate and validate canonical artifacts runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 - - name: Generate, validate, and test public artifacts + - name: Generate, validate, and test public artifacts once run: npm run test:ci + - name: Prepare desktop assets once + run: npm run desktop:prepare + + - name: Upload generated artifact set + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: act-generated-theme-assets + path: | + themes/registry.json + themes/*/manifest.json + themes/*/assets/** + themes/*/previews/** + packages/** + apps/theme-manager/build/** + retention-days: 2 + if-no-files-found: error + desktop: - needs: validate + name: Desktop ${{ matrix.os }} ${{ matrix.rust-target || 'native' }} + needs: prepare + runs-on: ${{ matrix.os }} permissions: - contents: write + contents: read strategy: fail-fast: false matrix: @@ -63,45 +103,46 @@ jobs: rust-target: "" build-args: "--bundles nsis" artifact: Awesome-Codex-Theme-Windows-x64 - release-asset-name: Awesome-Codex-Theme-[version]-Windows-x64[setup][ext] artifact-path: apps/theme-manager/src-tauri/target/release/bundle/nsis/*.exe - os: macos-14 rust-target: aarch64-apple-darwin build-args: "--target aarch64-apple-darwin --bundles app,dmg" artifact: Awesome-Codex-Theme-macOS-Apple-Silicon - release-asset-name: Awesome-Codex-Theme-[version]-macOS-Apple-Silicon[ext] artifact-path: apps/theme-manager/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg - os: macos-14 rust-target: x86_64-apple-darwin build-args: "--target x86_64-apple-darwin --bundles app,dmg" artifact: Awesome-Codex-Theme-macOS-Intel - release-asset-name: Awesome-Codex-Theme-[version]-macOS-Intel[ext] artifact-path: apps/theme-manager/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg - runs-on: ${{ matrix.os }} steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Download generated artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: act-generated-theme-assets + path: . - name: Set up Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm cache-dependency-path: apps/theme-manager/package-lock.json - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: targets: ${{ matrix.rust-target }} - name: Cache Rust build - uses: swatinem/rust-cache@v2 + uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: workspaces: apps/theme-manager/src-tauri -> target - - name: Generate, validate, and prepare desktop assets - run: npm run generate && npm run validate && npm run desktop:prepare - - name: Validate Windows portable helper if: runner.os == 'Windows' run: npm run installer:validate @@ -113,12 +154,11 @@ jobs: run: npm run desktop:check - name: Build platform-untrusted CI artifact - if: ${{ !startsWith(github.ref, 'refs/tags/v') || vars.DESKTOP_RELEASE_READY != 'true' }} working-directory: apps/theme-manager run: npm run tauri -- build ${{ matrix.build-args }} - name: Verify macOS disk image, bundle identity, and ad-hoc signature - if: ${{ (!startsWith(github.ref, 'refs/tags/v') || vars.DESKTOP_RELEASE_READY != 'true') && startsWith(matrix.os, 'macos-') }} + if: startsWith(matrix.os, 'macos-') shell: bash run: | artifacts=( ${{ matrix.artifact-path }} ) @@ -180,15 +220,126 @@ jobs: fi - name: Upload platform-untrusted CI artifact - if: ${{ !startsWith(github.ref, 'refs/tags/v') || vars.DESKTOP_RELEASE_READY != 'true' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ matrix.artifact }} path: ${{ matrix.artifact-path }} if-no-files-found: error + pages: + name: Build and verify gallery + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Download generated artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: act-generated-theme-assets + path: . + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + + - name: Build gallery from the canonical artifact set + run: npm run build + + - name: Assert live gallery behavior in Chromium + run: npm run gallery:check -- --screenshot gallery-home.png + + - name: Upload gallery screenshot evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: gallery-screenshot + path: gallery-home.png + if-no-files-found: warn + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 + with: + path: dist + + deploy: + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + needs: pages + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + steps: + - name: Deploy GitHub Pages + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 + + release: + if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' + needs: + - prepare + - desktop + name: Release ${{ matrix.os }} ${{ matrix.rust-target || 'native' }} + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rust-target: "" + build-args: "--bundles nsis" + release-asset-name: Awesome-Codex-Theme-[version]-Windows-x64[setup][ext] + - os: macos-14 + rust-target: aarch64-apple-darwin + build-args: "--target aarch64-apple-darwin --bundles app,dmg" + release-asset-name: Awesome-Codex-Theme-[version]-macOS-Apple-Silicon[ext] + - os: macos-14 + rust-target: x86_64-apple-darwin + build-args: "--target x86_64-apple-darwin --bundles app,dmg" + release-asset-name: Awesome-Codex-Theme-[version]-macOS-Intel[ext] + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Download generated artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: act-generated-theme-assets + path: . + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/theme-manager/package-lock.json + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + targets: ${{ matrix.rust-target }} + + - name: Cache Rust build + uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + workspaces: apps/theme-manager/src-tauri -> target + + - name: Install desktop dependencies + run: npm ci --prefix apps/theme-manager + - name: Check updater release credentials - if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' shell: bash env: ACT_UPDATER_PUBKEY: ${{ vars.TAURI_UPDATER_PUBKEY }} @@ -203,8 +354,7 @@ jobs: done - 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 + uses: tauri-apps/tauri-action@944946e3e4cac6603d1fe8f514171e9ecd3c78aa # v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACT_UPDATER_PUBKEY: ${{ vars.TAURI_UPDATER_PUBKEY }} @@ -221,10 +371,11 @@ jobs: releaseDraft: true - name: Verify release macOS disk image and ad-hoc signature - if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' && startsWith(matrix.os, 'macos-') + if: startsWith(matrix.os, 'macos-') shell: bash run: | - artifacts=( ${{ matrix.artifact-path }} ) + dmg_pattern="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/dmg/*.dmg" + artifacts=( $dmg_pattern ) if [ "${#artifacts[@]}" -ne 1 ]; then echo "::error::Expected exactly one release DMG, found ${#artifacts[@]}." exit 1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index a14e669..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Validate and publish gallery - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: github-pages - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up Node.js - uses: actions/setup-node@v7 - with: - node-version: 22 - - - name: Generate, validate, test, and build - run: npm run test:ci && npm run build - - - name: Capture gallery screenshot - shell: bash - run: | - node scripts/serve.mjs > server.log 2>&1 & - for attempt in {1..20}; do - if curl --fail --silent http://127.0.0.1:4173/ > /dev/null; then - break - fi - sleep 1 - done - google-chrome --headless=new --disable-gpu --no-sandbox --hide-scrollbars --window-size=1440,1100 --screenshot=gallery-home.png http://127.0.0.1:4173/ - test -s gallery-home.png - - - name: Upload screenshot evidence - uses: actions/upload-artifact@v7 - with: - name: gallery-screenshot - path: gallery-home.png - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: dist - - deploy: - if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - steps: - - name: Deploy GitHub Pages - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 0000000..703e7da --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,58 @@ +name: Lint workflow and supply-chain configuration + +on: + push: + branches: + - main + paths: + - ".github/dependabot.yml" + - ".github/workflows/**" + pull_request: + paths: + - ".github/dependabot.yml" + - ".github/workflows/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: supply-chain-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Lint GitHub Actions workflows + uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 + with: + reporter: github-pr-check + filter_mode: nofilter + fail_level: error + + zizmor: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Audit workflow security + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + with: + version: 1.28.0 + online-audits: "false" + advanced-security: "false" + annotations: "true" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a733bf..90fdb4a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Made CI generate the canonical theme artifacts once and reuse them across + desktop and Gallery jobs; pinned Actions, isolated release credentials and + write permission, added workflow security linting, and made stale runs + cancellable. - Reworked theme generation into a bounded, incremental pipeline with source-art provenance checks, complete render fingerprints, atomic per-theme writes, cache-aware progress, and reproducible benchmark commands. diff --git a/package.json b/package.json index ca39cc0..f4f2104 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "build": "node scripts/ensure-generated.mjs && node scripts/build.mjs", "check:full": "npm run test:ci && npm run desktop:check && npm run build", "check": "npm run check:full", + "gallery:check": "node scripts/verify-gallery.mjs", "serve": "node scripts/serve.mjs" }, "engines": { diff --git a/scripts/verify-gallery.mjs b/scripts/verify-gallery.mjs new file mode 100644 index 0000000..21320d9 --- /dev/null +++ b/scripts/verify-gallery.mjs @@ -0,0 +1,300 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(SCRIPT_DIR, ".."); +const DIST = path.join(ROOT, "dist"); +const DEFAULT_TIMEOUT_MS = 30_000; + +function usage() { + return "Usage: node scripts/verify-gallery.mjs [--screenshot=gallery-home.png]"; +} + +function parseOptions(argv = process.argv.slice(2)) { + const options = { screenshot: null }; + for (const argument of argv) { + if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument.startsWith("--screenshot=")) { + const value = argument.slice("--screenshot=".length).trim(); + if (!value) throw new Error("--screenshot requires a relative output path"); + options.screenshot = value; + } else { + throw new Error("Unknown gallery-check option: " + argument); + } + } + return options; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function reserveLoopbackPort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + if (!address || typeof address === "string") throw new Error("Could not reserve a loopback port"); + return address.port; +} + +async function waitFor(description, probe, timeoutMs = DEFAULT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value) return value; + } catch (error) { + lastError = error; + } + await sleep(100); + } + const detail = lastError ? ": " + lastError.message : ""; + throw new Error("Timed out waiting for " + description + detail); +} + +function startProcess(command, argumentsList, label) { + const child = spawn(command, argumentsList, { + cwd: ROOT, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + }); + let startupError = null; + let stderr = ""; + child.once("error", (error) => { + startupError = error; + }); + child.stderr.on("data", (chunk) => { + stderr = (stderr + chunk).slice(-4_000); + }); + return { + child, + assertRunning() { + if (startupError) throw new Error(label + " could not start: " + startupError.message); + if (child.exitCode !== null) throw new Error(label + " exited early" + (stderr ? ": " + stderr.trim() : "")); + }, + }; +} + +async function stopProcess(processRecord) { + if (!processRecord?.child || processRecord.child.exitCode !== null) return; + processRecord.child.kill(); + await Promise.race([ + new Promise((resolve) => processRecord.child.once("exit", resolve)), + sleep(2_000), + ]); + if (processRecord.child.exitCode === null) processRecord.child.kill("SIGKILL"); +} + +class DevToolsSession { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + this.events = []; + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message)); + else pending.resolve(message.result || {}); + } else if (message.method) { + this.events.push(message); + } + }); + socket.addEventListener("close", () => { + for (const pending of this.pending.values()) pending.reject(new Error("DevTools connection closed")); + this.pending.clear(); + }); + } + + send(method, params = {}) { + const id = this.nextId; + this.nextId += 1; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function connectDevTools(webSocketDebuggerUrl) { + const socket = new WebSocket(webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", () => reject(new Error("Could not connect to Chromium DevTools")), { once: true }); + }); + return new DevToolsSession(socket); +} + +async function evaluate(session, expression) { + const result = await session.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || "Runtime evaluation failed"); + } + return result.result?.value; +} + +function safeOutputPath(relativePath) { + const output = path.resolve(ROOT, relativePath); + const boundary = ROOT.endsWith(path.sep) ? ROOT : ROOT + path.sep; + if (!output.startsWith(boundary)) throw new Error("Screenshot output must remain inside the repository"); + return output; +} + +async function main(options) { + if (options.help) { + console.log(usage()); + return; + } + + const registry = JSON.parse(await readFile(path.join(DIST, "themes", "registry.json"), "utf8")); + if (!Array.isArray(registry.themes) || !registry.themes.length) { + throw new Error("Built gallery Registry does not contain themes"); + } + + const sitePort = await reserveLoopbackPort(); + const profile = await mkdtemp(path.join(os.tmpdir(), "act-gallery-check-")); + const server = startProcess(process.execPath, [path.join(SCRIPT_DIR, "serve.mjs"), "--port", String(sitePort)], "Gallery server"); + const browserCommand = process.env.ACT_CHROME_BIN || "google-chrome"; + const browser = startProcess(browserCommand, [ + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--disable-dev-shm-usage", + "--remote-allow-origins=*", + "--remote-debugging-port=0", + "--user-data-dir=" + profile, + "about:blank", + ], "Chromium"); + let session = null; + + try { + const siteUrl = "http://127.0.0.1:" + sitePort + "/"; + await waitFor("the gallery server", async () => { + server.assertRunning(); + const response = await fetch(siteUrl); + return response.ok; + }); + + const debugPort = await waitFor("Chromium DevTools", async () => { + browser.assertRunning(); + const activePort = await readFile(path.join(profile, "DevToolsActivePort"), "utf8").catch(() => null); + const port = Number(activePort?.split(/\r?\n/, 1)[0]); + return Number.isInteger(port) && port > 0 ? port : null; + }); + const targetResponse = await fetch( + "http://127.0.0.1:" + debugPort + "/json/new?" + encodeURIComponent("about:blank"), + { method: "PUT" }, + ); + if (!targetResponse.ok) throw new Error("Chromium could not create a gallery target"); + const target = await targetResponse.json(); + if (!target.webSocketDebuggerUrl) throw new Error("Chromium target did not provide a DevTools endpoint"); + + session = await connectDevTools(target.webSocketDebuggerUrl); + await Promise.all([ + session.send("Network.enable"), + session.send("Page.enable"), + session.send("Runtime.enable"), + ]); + await session.send("Page.navigate", { url: siteUrl }); + + const galleryState = await waitFor("the Registry-backed gallery", async () => { + const state = await evaluate(session, `(() => { + const gallery = document.querySelector("#gallery"); + const cards = [...document.querySelectorAll(".theme-card")]; + return { + busy: gallery?.getAttribute("aria-busy"), + cardCount: cards.length, + installButtonCount: document.querySelectorAll(".theme-card .card-install").length, + modeButtonCount: document.querySelectorAll(".theme-card .card-mode-toggle button").length, + title: document.title, + }; + })()`); + return state?.busy === "false" && state.cardCount === registry.themes.length ? state : null; + }); + if (galleryState.installButtonCount !== registry.themes.length + || galleryState.modeButtonCount !== registry.themes.length * 2 + || !galleryState.title) { + throw new Error("Gallery controls do not match the Registry-backed card set"); + } + + const focused = await evaluate(session, `(() => { + const button = document.querySelector(".theme-card .card-install"); + button?.focus(); + return document.activeElement === button; + })()`); + if (!focused) throw new Error("The first gallery install action is not keyboard focusable"); + await session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + }); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + }); + const dialogOpened = await waitFor("keyboard activation of the install dialog", async () => evaluate( + session, + `(() => document.querySelector("#installDialog")?.open === true && Boolean(document.querySelector("#dialogTitle")?.textContent?.trim()))()`, + ), 5_000); + if (!dialogOpened) throw new Error("Keyboard activation did not open a populated install dialog"); + + const requestFailures = session.events.filter((event) => (event.method === "Network.responseReceived" + && event.params.response.status >= 400 + && event.params.response.url.startsWith(siteUrl)) + || (event.method === "Network.loadingFailed" && !event.params.canceled)); + const runtimeFailures = session.events.filter((event) => event.method === "Runtime.exceptionThrown" + || (event.method === "Runtime.consoleAPICalled" && event.params.type === "error")); + if (requestFailures.length || runtimeFailures.length) { + throw new Error( + "Gallery browser check observed " + requestFailures.length + " HTTP failure(s) and " + + runtimeFailures.length + " runtime error(s)", + ); + } + + if (options.screenshot) { + const screenshot = await session.send("Page.captureScreenshot", { format: "png", fromSurface: true }); + await writeFile(safeOutputPath(options.screenshot), Buffer.from(screenshot.data, "base64")); + } + console.log( + "Gallery browser check passed: " + galleryState.cardCount + " cards, " + + galleryState.modeButtonCount + " mode controls, and keyboard dialog activation.", + ); + } finally { + session?.close(); + await Promise.all([stopProcess(browser), stopProcess(server)]); + await rm(profile, { recursive: true, force: true }); + } +} + +try { + await main(parseOptions()); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/tests/repository.test.mjs b/tests/repository.test.mjs index c3ecb5e..ffcc9ad 100644 --- a/tests/repository.test.mjs +++ b/tests/repository.test.mjs @@ -588,7 +588,9 @@ test("desktop beta release requires updater signing and discloses deferred platf readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "tauri.conf.json"), "utf8") ]); const tauriConfig = JSON.parse(tauriConfigText); - assert.match(workflow, /npm run generate && npm run validate && npm run desktop:prepare/); + assert.match(workflow, /run: npm run test:ci/); + assert.match(workflow, /run: npm run desktop:prepare/); + assert.match(workflow, /name: act-generated-theme-assets/); assert.match(workflow, /DESKTOP_RELEASE_READY/); assert.match(workflow, /TAURI_SIGNING_PRIVATE_KEY/); assert.match(workflow, /TAURI_SIGNING_PRIVATE_KEY_PASSWORD/); diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index 85cdac8..bbeebff 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -118,16 +118,41 @@ test("package scripts keep source checks fast and generated checks explicit", as assert.match(packageJson.scripts["test:generated"], /ensure-generated\.mjs/); assert.match(packageJson.scripts["test:ci"], /^npm run generate/); assert.match(packageJson.scripts["check:full"], /npm run test:ci/); + assert.equal(packageJson.scripts["gallery:check"], "node scripts/verify-gallery.mjs"); }); -test("CI invokes the complete source and generated test contract", async function () { - const [desktopWorkflow, pagesWorkflow] = await Promise.all([ +test("CI reuses canonical generated assets with pinned, least-privilege workflows", async function () { + const [desktopWorkflow, supplyChainWorkflow, dependabot] = await Promise.all([ readFile(path.join(ROOT, ".github", "workflows", "desktop.yml"), "utf8"), - readFile(path.join(ROOT, ".github", "workflows", "pages.yml"), "utf8"), + readFile(path.join(ROOT, ".github", "workflows", "supply-chain.yml"), "utf8"), + readFile(path.join(ROOT, ".github", "dependabot.yml"), "utf8"), ]); assert.match(desktopWorkflow, /run: npm run test:ci/); - assert.match(pagesWorkflow, /run: npm run test:ci && npm run build/); + assert.match(desktopWorkflow, /name: act-generated-theme-assets/); + assert.match(desktopWorkflow, /uses: actions\/download-artifact@[0-9a-f]{40}/); + assert.match(desktopWorkflow, /run: npm run gallery:check/); + assert.match(desktopWorkflow, /cancel-in-progress: true/); + assert.match(desktopWorkflow, /- "screenshots\/\*\*"/); + assert.match(desktopWorkflow, /- "LICENSE"/); + assert.match(desktopWorkflow, /- "NOTICE\.md"/); + const desktopJob = desktopWorkflow.slice( + desktopWorkflow.indexOf("\n desktop:"), + desktopWorkflow.indexOf("\n pages:"), + ); + const releaseJob = desktopWorkflow.slice(desktopWorkflow.indexOf("\n release:")); + assert.match(releaseJob, /permissions:\n\s+contents: write/); + assert.doesNotMatch(desktopJob, /contents: write/); + assert.match(desktopWorkflow, /uses: actions\/checkout@[0-9a-f]{40} # v7/); + assert.match(supplyChainWorkflow, /reviewdog\/action-actionlint@[0-9a-f]{40}/); + assert.match(supplyChainWorkflow, /zizmorcore\/zizmor-action@[0-9a-f]{40}/); + assert.match(dependabot, /package-ecosystem: github-actions/); + assert.match(dependabot, /package-ecosystem: cargo/); + for (const workflow of [desktopWorkflow, supplyChainWorkflow]) { + for (const action of workflow.matchAll(/^\s*uses:\s+[^@\s]+@([^\s#]+)/gm)) { + assert.match(action[1], /^[0-9a-f]{40}$/); + } + } }); test("incremental generator options stay bounded and explicit", function () { From f90ea9134d4e5580f004f0c42c3a1311b5779220 Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 12:08:05 -0400 Subject: [PATCH 2/6] ci: prevent cache poisoning in workflow checks --- .github/dependabot.yml | 6 ++++ .github/workflows/desktop.yml | 60 ++++++++++++++++++---------------- tests/source-contract.test.mjs | 3 ++ 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f255af2..d579fd2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 groups: github-actions: patterns: @@ -13,8 +15,12 @@ updates: directory: /apps/theme-manager schedule: interval: weekly + cooldown: + default-days: 7 - package-ecosystem: cargo directory: /apps/theme-manager/src-tauri schedule: interval: weekly + cooldown: + default-days: 7 diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 0cd3f85..41b2df7 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -68,6 +68,7 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 + package-manager-cache: false - name: Generate, validate, and test public artifacts once run: npm run test:ci @@ -130,19 +131,13 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 - cache: npm - cache-dependency-path: apps/theme-manager/package-lock.json + package-manager-cache: false - name: Set up Rust uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: targets: ${{ matrix.rust-target }} - - name: Cache Rust build - uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - with: - workspaces: apps/theme-manager/src-tauri -> target - - name: Validate Windows portable helper if: runner.os == 'Windows' run: npm run installer:validate @@ -161,7 +156,9 @@ jobs: if: startsWith(matrix.os, 'macos-') shell: bash run: | - artifacts=( ${{ matrix.artifact-path }} ) + dmg_dir="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/dmg" + shopt -s nullglob + artifacts=("$dmg_dir"/*.dmg) if [ "${#artifacts[@]}" -ne 1 ]; then echo "::error::Expected exactly one macOS DMG, found ${#artifacts[@]}." exit 1 @@ -177,11 +174,12 @@ jobs: trap cleanup EXIT hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" - app_path="$(find "$mount_dir" -maxdepth 1 -type d -name '*.app' -print -quit)" - if [ -z "$app_path" ]; then - echo "::error::The DMG does not contain a macOS application bundle." + apps=("$mount_dir"/*.app) + if [ "${#apps[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS application bundle." exit 1 fi + app_path="${apps[0]}" info_plist="$app_path/Contents/Info.plist" bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info_plist")" @@ -249,6 +247,7 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 + package-manager-cache: false - name: Build gallery from the canonical artifact set run: npm run build @@ -323,19 +322,13 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 - cache: npm - cache-dependency-path: apps/theme-manager/package-lock.json + package-manager-cache: false - name: Set up Rust uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: targets: ${{ matrix.rust-target }} - - name: Cache Rust build - uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - with: - workspaces: apps/theme-manager/src-tauri -> target - - name: Install desktop dependencies run: npm ci --prefix apps/theme-manager @@ -374,8 +367,9 @@ jobs: if: startsWith(matrix.os, 'macos-') shell: bash run: | - dmg_pattern="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/dmg/*.dmg" - artifacts=( $dmg_pattern ) + dmg_dir="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/dmg" + shopt -s nullglob + artifacts=("$dmg_dir"/*.dmg) if [ "${#artifacts[@]}" -ne 1 ]; then echo "::error::Expected exactly one release DMG, found ${#artifacts[@]}." exit 1 @@ -390,11 +384,12 @@ jobs: } trap cleanup EXIT hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" - app_path="$(find "$mount_dir" -maxdepth 1 -type d -name '*.app' -print -quit)" - if [ -z "$app_path" ]; then - echo "::error::The release DMG does not contain a macOS application bundle." + apps=("$mount_dir"/*.app) + if [ "${#apps[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS application bundle." exit 1 fi + app_path="${apps[0]}" codesign --verify --deep --strict --verbose=2 "$app_path" signing_details="$(codesign --display --verbose=4 "$app_path" 2>&1)" @@ -404,11 +399,18 @@ jobs: exit 1 fi - updater_archives=( apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/macos/*.app.tar.gz ) - updater_signatures=( apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/macos/*.app.tar.gz.sig ) - if [ "${#updater_archives[@]}" -ne 1 ] || [ "${#updater_signatures[@]}" -ne 1 ]; then - echo "::error::Expected one macOS updater archive and one signature." + updater_dir="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/macos" + updater_archives=("$updater_dir"/*.app.tar.gz) + if [ "${#updater_archives[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS updater archive." + exit 1 + fi + updater_archive="${updater_archives[0]}" + updater_signatures=("$updater_dir"/*.app.tar.gz.sig) + if [ "${#updater_signatures[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS updater signature." exit 1 fi - test -s "${updater_archives[0]}" - test -s "${updater_signatures[0]}" + updater_signature="${updater_signatures[0]}" + test -s "$updater_archive" + test -s "$updater_signature" diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index bbeebff..9aa1c4f 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -143,6 +143,9 @@ test("CI reuses canonical generated assets with pinned, least-privilege workflow const releaseJob = desktopWorkflow.slice(desktopWorkflow.indexOf("\n release:")); assert.match(releaseJob, /permissions:\n\s+contents: write/); assert.doesNotMatch(desktopJob, /contents: write/); + assert.doesNotMatch(desktopWorkflow, /cache: npm/); + assert.match(desktopWorkflow, /package-manager-cache: false/); + assert.doesNotMatch(desktopWorkflow, /swatinem\/rust-cache/); assert.match(desktopWorkflow, /uses: actions\/checkout@[0-9a-f]{40} # v7/); assert.match(supplyChainWorkflow, /reviewdog\/action-actionlint@[0-9a-f]{40}/); assert.match(supplyChainWorkflow, /zizmorcore\/zizmor-action@[0-9a-f]{40}/); From 9364ab9d2717304113d8e5ea0987ba09f1395dec Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 12:30:35 -0400 Subject: [PATCH 3/6] fix: accept gallery screenshot CLI argument forms --- scripts/verify-gallery.mjs | 24 +++++++++++++++++------- tests/source-contract.test.mjs | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/scripts/verify-gallery.mjs b/scripts/verify-gallery.mjs index 21320d9..b2627f6 100644 --- a/scripts/verify-gallery.mjs +++ b/scripts/verify-gallery.mjs @@ -14,11 +14,19 @@ function usage() { return "Usage: node scripts/verify-gallery.mjs [--screenshot=gallery-home.png]"; } -function parseOptions(argv = process.argv.slice(2)) { +export function parseOptions(argv = process.argv.slice(2)) { const options = { screenshot: null }; - for (const argument of argv) { + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; if (argument === "--help" || argument === "-h") { options.help = true; + } else if (argument === "--screenshot") { + const value = argv[index + 1]?.trim(); + if (!value || value.startsWith("--")) { + throw new Error("--screenshot requires a relative output path"); + } + options.screenshot = value; + index += 1; } else if (argument.startsWith("--screenshot=")) { const value = argument.slice("--screenshot=".length).trim(); if (!value) throw new Error("--screenshot requires a relative output path"); @@ -292,9 +300,11 @@ async function main(options) { } } -try { - await main(parseOptions()); -} catch (error) { - console.error(error.message); - process.exitCode = 1; +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await main(parseOptions()); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } } diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index 9aa1c4f..4b87080 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -20,6 +20,7 @@ import { renderFingerprint, SOURCE_ART_RENDERER_CONSTANTS, } from "../scripts/lib/source-art-contract.mjs"; +import { parseOptions as parseGalleryCheckOptions } from "../scripts/verify-gallery.mjs"; import { contrastRatio, isSafeRelativePath } from "../scripts/validate.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -121,6 +122,21 @@ test("package scripts keep source checks fast and generated checks explicit", as assert.equal(packageJson.scripts["gallery:check"], "node scripts/verify-gallery.mjs"); }); +test("gallery check accepts both standard screenshot argument forms", function () { + assert.equal( + parseGalleryCheckOptions(["--screenshot=gallery-home.png"]).screenshot, + "gallery-home.png", + ); + assert.equal( + parseGalleryCheckOptions(["--screenshot", "gallery-home.png"]).screenshot, + "gallery-home.png", + ); + assert.throws( + () => parseGalleryCheckOptions(["--screenshot"]), + /--screenshot requires a relative output path/, + ); +}); + test("CI reuses canonical generated assets with pinned, least-privilege workflows", async function () { const [desktopWorkflow, supplyChainWorkflow, dependabot] = await Promise.all([ readFile(path.join(ROOT, ".github", "workflows", "desktop.yml"), "utf8"), From 4087692683a4caa7193d820c3348b85ca3e7fa19 Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 13:10:16 -0400 Subject: [PATCH 4/6] fix: retry gallery browser profile cleanup --- scripts/verify-gallery.mjs | 42 +++++++++++++++++++++++++++------- tests/source-contract.test.mjs | 7 ++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/scripts/verify-gallery.mjs b/scripts/verify-gallery.mjs index b2627f6..8498e9a 100644 --- a/scripts/verify-gallery.mjs +++ b/scripts/verify-gallery.mjs @@ -9,6 +9,9 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(SCRIPT_DIR, ".."); const DIST = path.join(ROOT, "dist"); const DEFAULT_TIMEOUT_MS = 30_000; +const PROCESS_STOP_TIMEOUT_MS = 2_000; +const PROFILE_REMOVE_RETRIES = 10; +const PROFILE_REMOVE_RETRY_DELAY_MS = 250; function usage() { return "Usage: node scripts/verify-gallery.mjs [--screenshot=gallery-home.png]"; @@ -94,13 +97,36 @@ function startProcess(command, argumentsList, label) { } async function stopProcess(processRecord) { - if (!processRecord?.child || processRecord.child.exitCode !== null) return; - processRecord.child.kill(); - await Promise.race([ - new Promise((resolve) => processRecord.child.once("exit", resolve)), - sleep(2_000), - ]); - if (processRecord.child.exitCode === null) processRecord.child.kill("SIGKILL"); + if (!processRecord?.child) return; + const { child } = processRecord; + const exited = () => child.exitCode !== null || child.signalCode !== null; + const waitForExit = async () => { + if (exited()) return; + await Promise.race([ + new Promise((resolve) => child.once("exit", resolve)), + sleep(PROCESS_STOP_TIMEOUT_MS), + ]); + }; + if (!exited()) { + child.kill(); + await waitForExit(); + } + if (!exited()) { + child.kill("SIGKILL"); + await waitForExit(); + } +} + +async function removeProfile(profile) { + // Chromium can finish closing child processes just after its parent exits. + // Retrying an isolated temporary directory prevents that race from turning a + // successful browser assertion into a failed CI run. + await rm(profile, { + recursive: true, + force: true, + maxRetries: PROFILE_REMOVE_RETRIES, + retryDelay: PROFILE_REMOVE_RETRY_DELAY_MS, + }); } class DevToolsSession { @@ -296,7 +322,7 @@ async function main(options) { } finally { session?.close(); await Promise.all([stopProcess(browser), stopProcess(server)]); - await rm(profile, { recursive: true, force: true }); + await removeProfile(profile); } } diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index 4b87080..8288aeb 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -137,6 +137,13 @@ test("gallery check accepts both standard screenshot argument forms", function ( ); }); +test("gallery browser check retries isolated profile cleanup after Chromium exits", async function () { + const galleryCheck = await readFile(path.join(ROOT, "scripts", "verify-gallery.mjs"), "utf8"); + assert.match(galleryCheck, /maxRetries:\s*PROFILE_REMOVE_RETRIES/); + assert.match(galleryCheck, /retryDelay:\s*PROFILE_REMOVE_RETRY_DELAY_MS/); + assert.match(galleryCheck, /child\.signalCode !== null/); +}); + test("CI reuses canonical generated assets with pinned, least-privilege workflows", async function () { const [desktopWorkflow, supplyChainWorkflow, dependabot] = await Promise.all([ readFile(path.join(ROOT, ".github", "workflows", "desktop.yml"), "utf8"), From 9e93a38cd29f14143cbda3aa08d98c4b7bdc2edd Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 13:30:45 -0400 Subject: [PATCH 5/6] fix: emit Enter text in gallery browser check --- scripts/verify-gallery.mjs | 2 ++ tests/source-contract.test.mjs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/scripts/verify-gallery.mjs b/scripts/verify-gallery.mjs index 8498e9a..1853fa0 100644 --- a/scripts/verify-gallery.mjs +++ b/scripts/verify-gallery.mjs @@ -282,6 +282,8 @@ async function main(options) { type: "keyDown", key: "Enter", code: "Enter", + text: "\r", + unmodifiedText: "\r", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, }); diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index 8288aeb..2e1d892 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -142,6 +142,8 @@ test("gallery browser check retries isolated profile cleanup after Chromium exit assert.match(galleryCheck, /maxRetries:\s*PROFILE_REMOVE_RETRIES/); assert.match(galleryCheck, /retryDelay:\s*PROFILE_REMOVE_RETRY_DELAY_MS/); assert.match(galleryCheck, /child\.signalCode !== null/); + assert.match(galleryCheck, /text:\s*"\\r"/); + assert.match(galleryCheck, /unmodifiedText:\s*"\\r"/); }); test("CI reuses canonical generated assets with pinned, least-privilege workflows", async function () { From 629052dcc739d89a54d634e383ea1efc0425aefe Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 14:12:09 -0400 Subject: [PATCH 6/6] fix: ignore Chromium favicon fallback in gallery check --- scripts/verify-gallery.mjs | 26 +++++++++++++++++++++----- tests/source-contract.test.mjs | 29 ++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/scripts/verify-gallery.mjs b/scripts/verify-gallery.mjs index 1853fa0..f96750c 100644 --- a/scripts/verify-gallery.mjs +++ b/scripts/verify-gallery.mjs @@ -195,6 +195,18 @@ function safeOutputPath(relativePath) { return output; } +export function isGalleryRequestFailure(event, siteUrl) { + const responseFailure = event.method === "Network.responseReceived" + && event.params.response.status >= 400 + && event.params.response.url.startsWith(siteUrl); + const browserFaviconFallback = responseFailure + && event.params.type === "Other" + && event.params.response.status === 404 + && event.params.response.url === new URL("/favicon.ico", siteUrl).href; + return (responseFailure && !browserFaviconFallback) + || (event.method === "Network.loadingFailed" && !event.params.canceled); +} + async function main(options) { if (options.help) { console.log(usage()); @@ -300,16 +312,20 @@ async function main(options) { ), 5_000); if (!dialogOpened) throw new Error("Keyboard activation did not open a populated install dialog"); - const requestFailures = session.events.filter((event) => (event.method === "Network.responseReceived" - && event.params.response.status >= 400 - && event.params.response.url.startsWith(siteUrl)) - || (event.method === "Network.loadingFailed" && !event.params.canceled)); + const requestFailures = session.events.filter((event) => isGalleryRequestFailure(event, siteUrl)); const runtimeFailures = session.events.filter((event) => event.method === "Runtime.exceptionThrown" || (event.method === "Runtime.consoleAPICalled" && event.params.type === "error")); if (requestFailures.length || runtimeFailures.length) { + const requestFailureDetails = requestFailures.map((event) => { + if (event.method === "Network.responseReceived") { + return event.params.type + " " + event.params.response.status + " " + event.params.response.url; + } + return event.params.errorText || "Network loading failed"; + }).join("; "); throw new Error( "Gallery browser check observed " + requestFailures.length + " HTTP failure(s) and " - + runtimeFailures.length + " runtime error(s)", + + runtimeFailures.length + " runtime error(s)" + + (requestFailureDetails ? ": " + requestFailureDetails : ""), ); } diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index 2e1d892..ff71d5e 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -20,7 +20,10 @@ import { renderFingerprint, SOURCE_ART_RENDERER_CONSTANTS, } from "../scripts/lib/source-art-contract.mjs"; -import { parseOptions as parseGalleryCheckOptions } from "../scripts/verify-gallery.mjs"; +import { + isGalleryRequestFailure, + parseOptions as parseGalleryCheckOptions, +} from "../scripts/verify-gallery.mjs"; import { contrastRatio, isSafeRelativePath } from "../scripts/validate.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -137,6 +140,30 @@ test("gallery check accepts both standard screenshot argument forms", function ( ); }); +test("gallery browser check ignores Chromium's undeclared favicon fallback", function () { + const siteUrl = "http://127.0.0.1:4173/"; + assert.equal( + isGalleryRequestFailure({ + method: "Network.responseReceived", + params: { + type: "Other", + response: { status: 404, url: siteUrl + "favicon.ico" }, + }, + }, siteUrl), + false, + ); + assert.equal( + isGalleryRequestFailure({ + method: "Network.responseReceived", + params: { + type: "Fetch", + response: { status: 404, url: siteUrl + "themes/registry.json" }, + }, + }, siteUrl), + true, + ); +}); + test("gallery browser check retries isolated profile cleanup after Chromium exits", async function () { const galleryCheck = await readFile(path.join(ROOT, "scripts", "verify-gallery.mjs"), "utf8"); assert.match(galleryCheck, /maxRetries:\s*PROFILE_REMOVE_RETRIES/);