fix(icons): commit generated icons; installs and regular builds never render

User installs failed with 'resvg-py is missing' because the web/desktop
source builds rendered icons on whatever python was on PATH. The default
brand outputs are now committed; source_build, apps/desktop build.mjs and
the npm/docusaurus pre-hooks consume them directly. Flavored release
bundles (canary/commit) still render into their own product dir.

icons-freshness-check now regenerates and fails on any byte diff.
This commit is contained in:
ethernet
2026-09-24 19:17:34 -04:00
parent 27df3b8847
commit 4b7229d612
48 changed files with 56 additions and 93 deletions

View File

@@ -1,13 +1,11 @@
name: Icon Assets Freshness
# Guard for the icon pipeline. The sources of truth are the nous-girl SVGs
# (assets/nous-girl-*.svg) and the per-platform backgrounds
# (assets/backgrounds/*.svg). Generated outputs are NOT committed — every
# consuming pipeline regenerates them via scripts/generate-icons.mjs — so
# this lane regenerates everything from a fresh checkout and runs --check,
# which asserts structural invariants (sizes, transparent squircle corners,
# container frame sets). A source change can never land broken. See
# scripts/generate_icons.py.
# The committed icon outputs must match what scripts/generate_icons.py
# renders from assets/nous-girl-*.svg + assets/backgrounds/*.svg. Regular
# builds and user installs consume the committed files and never render, so
# this lane is what keeps them honest: regenerate, structural --check, then
# fail on any byte difference. Fix a red run with
# `node scripts/generate-icons.mjs` on a Hermes runtime interpreter and commit.
#
# Byte-determinism note: resvg renders the same SVG to identical bytes on any
# host for pure-shape artwork. If the master ever contains <text>, font
@@ -22,7 +20,7 @@ permissions:
jobs:
check:
name: Regenerate icons + structural check
name: Regenerate icons + diff against committed
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -36,7 +34,15 @@ jobs:
cache-python: true
prune-python-cache: true
- name: Regenerate every target, then verify structure
- name: Regenerate every target, verify structure, diff against committed
env:
HERMES_PAYLOAD_TAG: ''
HERMES_BUILD_COMMIT: ''
run: |
node scripts/generate-icons.mjs
node scripts/generate-icons.mjs --check
if ! git diff --exit-code --stat || [ -n "$(git status --porcelain --untracked-files=all)" ]; then
git status --porcelain --untracked-files=all
echo "::error::Committed icons are stale. Run 'node scripts/generate-icons.mjs' and commit the result."
exit 1
fi

View File

@@ -38,20 +38,6 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# tauri::generate_context! embeds the app icons, which are generated,
# not committed. The generator runs on a Hermes runtime interpreter; []
# exports one as HERMES_PYTHON.
- uses: ./.github/actions/setup-pm
with:
toolchain: all
extras: '[]'
cache-python: true
prune-python-cache: true
- name: Generate icons
working-directory: .
run: node scripts/generate-icons.mjs
# Tauri links against the system webkit2gtk on Linux, so the crate does
# not compile without these even for `cargo test --lib`.
- name: Install Tauri system dependencies

28
.gitignore vendored
View File

@@ -318,32 +318,4 @@ native/fts5_cjk/*.so
# PM lockfile advisory lock; kept in place so waiting writers share its inode
/pm/.lock.json.lock
# Generated icon assets (scripts/generate_icons.py). Regenerated on demand by
# every consuming pipeline; sources of truth: assets/nous-girl-*.svg + assets/backgrounds/.
assets/icon-master.svg
assets/icon-master-dark.svg
apps/desktop/assets/icon.png
apps/desktop/assets/icon.ico
apps/desktop/assets/icon.icns
apps/desktop/assets/icon-dark.png
apps/desktop/assets/icon-dark.ico
apps/desktop/assets/icon-dark.icns
apps/desktop/assets/appx/*.png
apps/desktop/public/apple-touch-icon.png
apps/desktop/public/nous-girl.png
apps/desktop/public/nous-girl-dark.png
apps/desktop/.dist-build*
apps/bootstrap-installer/src-tauri/icons/*.png
apps/bootstrap-installer/src-tauri/icons/icon.ico
apps/bootstrap-installer/src-tauri/icons/icon.icns
apps/bootstrap-installer/public/nous-girl.png
website/static/img/logo.png
website/static/img/logo-dark.png
website/static/img/nous-logo.png
website/static/img/nous-logo-dark.png
website/static/img/favicon-16x16.png
website/static/img/favicon-32x32.png
website/static/img/apple-touch-icon.png
website/static/img/favicon.ico
website/static/img/favicon.svg
web/public/favicon.ico

View File

@@ -197,7 +197,8 @@ Native desktop dependencies can also require the platform build toolchain.
Logos and icons are generated from `assets/nous-girl-*.svg` and
`assets/backgrounds/`. `node scripts/generate-icons.mjs` renders them with the
Hermes runtime Python (`HERMES_PYTHON`, else `python` on PATH): Pillow and
resvg-py are core dependencies. Do not commit generated PNG/ICO/ICNS outputs.
resvg-py are core dependencies. Generated outputs are committed and CI fails if
they are stale; rerun the generator and commit after changing any source SVG.
### Run tests

View File

@@ -6,7 +6,6 @@
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5175",
"prebuild": "node ../../scripts/generate-icons.mjs --source ../.. --out ../..",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -19,7 +19,7 @@
"clean:e2e": "tsc --build tsconfig.e2e.json --clean",
"clean:renderer": "tsc --build tsconfig.json --clean ",
"clean:electron": "tsc --build tsconfig.electron.json --clean",
"dev": "node ../../scripts/generate-icons.mjs && concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:mock": "node ../../tests-js/scripts/mock-server.ts",
"dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174",

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -3,19 +3,15 @@ import { execFileSync } from 'node:child_process'
import { cpSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { generateIcons } from '../../../scripts/generate-icons.mjs'
import { isMain, repoRoot } from '../../../scripts/build/frontend-common.mjs'
export function buildSourceDesktop({ source = repoRoot, icons, run = execFileSync, generate = generateIcons } = {}) {
export function buildSourceDesktop({ source = repoRoot, icons, run = execFileSync } = {}) {
source = resolve(source)
const app = join(source, 'apps/desktop')
const step = (script, args = []) => run(process.execPath, [join(source, script), ...args], { cwd: app, stdio: 'inherit' })
step('apps/desktop/scripts/assert-root-install.mjs')
if (!icons) {
if (generate(['--source', source, '--out', source]) !== 0) throw new Error('Icon preparation failed')
icons = source
}
icons = resolve(icons)
// Default-brand icons are committed; only flavored release builds pass --icons.
icons = resolve(icons ?? source)
if (icons !== source) {
// electron-builder consumes packaging artwork in the workspace. Copy the
// prepared pixels; do not create another Python environment to redraw them.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 135 KiB

7
assets/icon-master.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -85,9 +85,8 @@ def build_source_tui(project_root: Path, *, env: dict) -> None:
def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None) -> None:
if icons is None:
icons = project_root
run_source_script(project_root, "scripts/generate-icons.mjs", env=env, label="Generating icons")
# Default-brand icons are committed; installs never render them.
icons = icons or project_root
run_source_script(project_root, "scripts/build/web.mjs", "--source", str(project_root),
"--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env,
label="Building the web UI")

View File

@@ -36,13 +36,9 @@ She renders in front of the border, clipped only to the outer rounded silhouette
Only nodes near her bottom edge extend to the border; the fitted face and hair stay fixed.
Standalone wordmarks remain centered and have no border.
GENERATED OUTPUTS ARE NOT COMMITTED. Everything this script writes is
gitignored and regenerated on demand by the consuming pipelines (website
prebuild, desktop prebuild/predev, installer prebuild, web prebuild — via
scripts/generate-icons.mjs). The freshness lane (icons-freshness-check.yml)
runs --check, which regenerates in memory and asserts structural invariants
(sizes, transparency, container frame sets) — there are no committed bytes to
byte-compare against.
GENERATED OUTPUTS ARE COMMITTED. Regular builds and installs consume them and
never render; flavored release bundles (canary/commit) render to a product dir.
icons-freshness-check.yml regenerates, runs --check, and fails on any diff.
Rendering: resvg (resvg-py) for SVG -> PNG fidelity at every size.
Containers: Pillow for multi-size .ico and .icns.

View File

@@ -15,7 +15,6 @@ test('desktop development composition reuses prepared icon pixels instead of pro
put(join(input.icons, 'apps/desktop/assets/icon.ico'), 'prepared packaging icon')
const commands = []
buildSourceDesktop({ source: input.source, icons: input.icons,
generate: () => { throw new Error('prepared icons must not regenerate') },
run: (command, args) => commands.push([command, ...args]),
})
expect(readFileSync(join(input.source, 'apps/desktop/assets/icon.ico'), 'utf8')).toBe('prepared packaging icon')

View File

@@ -167,7 +167,7 @@ def source_products(source_checkout):
encoding="utf-8",
)
for script, step, output in [
("generate-icons.mjs", "icons", "web/public/favicon.ico"),
("generate-icons.mjs", "icons", "never-rendered-at-install"),
("build/tui.mjs", "tui", "ui-tui/dist/entry.js"),
("build/web.mjs", "web", "hermes_cli/web_dist/index.html"),
]:
@@ -188,6 +188,9 @@ def source_products(source_checkout):
"if (!process.argv.includes('--icons')) await import('../generate-icons.mjs');\n",
encoding="utf-8",
)
# Default-brand icons are committed; updates consume them without rendering.
(root / "web/public").mkdir(parents=True, exist_ok=True)
(root / "web/public/favicon.ico").write_bytes(b"committed icon")
return root, acquired
@@ -252,7 +255,7 @@ def test_update_builds_selected_products_after_one_union_preparation(source_prod
app.write_text("previous app")
build_update_products(root, desktop=desktop)
steps = [event["step"] for event in _events(root)]
assert steps == ["deps", "tui", "icons", "web"] + (["desktop"] if desktop else [])
assert steps == ["deps", "tui", "web"] + (["desktop"] if desktop else [])
assert acquired == ["npm"]
assert (root / "ui-tui/dist/entry.js").read_text() == "tui"
assert (root / "hermes_cli/web_dist/index.html").read_text() == "web"
@@ -264,7 +267,7 @@ def test_update_builds_selected_products_after_one_union_preparation(source_prod
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("step", ["tui", "icons", "web", "desktop"])
@pytest.mark.parametrize("step", ["tui", "web", "desktop"])
def test_update_failure_raises_without_retries_or_replacing_live_app(source_products, step):
from hermes_cli.source_build import build_update_products
@@ -277,7 +280,7 @@ def test_update_failure_raises_without_retries_or_replacing_live_app(source_prod
build_update_products(root, desktop=True)
assert app.read_text() == "previous app"
assert not list((root / "apps/desktop").glob(".staging-*"))
order = ["deps", "tui", "icons", "web", "desktop"]
order = ["deps", "tui", "web", "desktop"]
assert [event["step"] for event in _events(root)] == order[:order.index(step) + 1]
assert acquired == ["npm"]
assert not (Path(os.environ["HERMES_HOME"]) / "desktop-build-stamp.json").exists()

View File

@@ -4,9 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"predev": "node ../scripts/generate-icons.mjs --source .. --out ..",
"dev": "vite",
"prebuild": "node ../scripts/generate-icons.mjs --source .. --out ..",
"build": "node ../scripts/build/web.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -33,7 +33,6 @@ const repoRoot = resolve(websiteDir, "..");
const extractScript = join(scriptDir, "extract-skills.py");
const llmsScript = join(scriptDir, "generate-llms-txt.py");
const cronBlueprintsScript = join(scriptDir, "extract-automation-blueprints.py");
const iconGenScript = join(repoRoot, "scripts", "generate-icons.mjs");
const pluginsScript = join(scriptDir, "extract-plugins.py");
const pluginStarsScript = join(scriptDir, "fetch-plugin-stars.py");
const outputFile = join(websiteDir, "static", "api", "skills.json");
@@ -125,19 +124,7 @@ async function ensureUnifiedIndex() {
}
}
// 0) Icon assets — the navbar logo, favicons, and apple-touch-icon are
// generated (not committed). This must fail loudly: a docs build without the
// logo ships a broken navbar.
console.log("[prebuild] generating icon assets…");
{
const r = spawnSync("node", [iconGenScript, "--source", repoRoot, "--out", repoRoot], { stdio: "inherit", cwd: repoRoot });
if (r.status !== 0) {
console.error("[prebuild] icon generation failed — it needs a Hermes runtime Python (HERMES_PYTHON); see the output above");
process.exit(1);
}
}
// 0b) Pull unified index if we don't have a fresh one.
// 0) Pull unified index if we don't have a fresh one.
await ensureUnifiedIndex();
// 1) skills.json — required for the Skills Hub page.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
website/static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB