diff --git a/demo/golden/baselines/baseline-manifest.json b/demo/golden/baselines/baselines-index.json similarity index 100% rename from demo/golden/baselines/baseline-manifest.json rename to demo/golden/baselines/baselines-index.json diff --git a/demo/golden/policy.mjs b/demo/golden/policy.mjs index 5817a7a9..202b16d9 100644 --- a/demo/golden/policy.mjs +++ b/demo/golden/policy.mjs @@ -1,4 +1,7 @@ -export const GOLDEN_BASELINE_MANIFEST = 'baseline-manifest.json'; +// The name must NOT end with `manifest.json`: the HACS submission check globs +// `*manifest.json` over the whole clone of the default branch and refuses a +// repository with more than one match (test/repo-hygiene.test.mjs). +export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json'; export const assertGoldenInvocation = (mode, scenarioFilter = '') => { if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`); diff --git a/demo/stand/README.md b/demo/stand/README.md index 8824594f..17db5b5f 100644 --- a/demo/stand/README.md +++ b/demo/stand/README.md @@ -4,10 +4,10 @@ Things that live on the public stand (demo.houseplan.tech) but are not part of the shipped integration. - `demo_robot/` — the scripted robot vacuum (docs/VACUUM.md, "demo stand gets - a scripted synthetic robot"). Deployed by copying the folder to - `custom_components/demo_robot` in both stand seeds and adding `demo_robot:` - to configuration.yaml; the dev stand picks it up automatically from this - path (`/opt/hp/bin/hp-update-dev.sh`). The rest of the stand-only config + a scripted synthetic robot"). Deployed with `./install.sh ` + into both stand seeds, plus `demo_robot:` in configuration.yaml; the dev + stand picks it up automatically from this path + (`/opt/hp/bin/hp-update-dev.sh`, which calls the same script). The rest of the stand-only config (template LQI sensors, alarm helpers, the smoke automation) lives in the seeds on the stand host — see `docs/TESTING-DEMO.md` and the memory note `houseplan-demo-stand`. @@ -16,8 +16,8 @@ of the shipped integration. administrator (the card editor is gated on `is_admin`) and kept restarting HA from the UI, which looked like the stand crashing between hourly resets. The component re-registers `homeassistant.restart`/`homeassistant.stop` as - no-ops after startup. Deployed to `custom_components/demo_guard` in - seed-demo only (the dev stand sits behind basic auth) + `demo_guard:` in + no-ops after startup. Deployed by the same `install.sh` into seed-demo only + (the dev stand sits behind basic auth) + `demo_guard:` in configuration.yaml. - `www/stand-reset-timer.js` — console-only countdown to the next hourly @@ -27,3 +27,16 @@ of the shipped integration. switching to `console.warn` for the last 5 minutes. `www/stand-dev-info.js` is the dev-stand counterpart: a single `console.info` saying the dev stand only resets on deploy. + +## Why the manifests are templates + +Both components ship their manifest as `manifest.template.json`, and +`install.sh` renames it to `manifest.json` on the stand. + +The reason is the HACS submission check: `hacs/default` validates a repository +by globbing `*manifest.json` over the whole clone of the DEFAULT branch and +refuses anything that does not have exactly one +(`scripts/helpers/integration_path.py` — "No manifest", exit 1). Two stand-only +manifests turned the Hassfest job of PR #9004 red on 2026-08-11, five weeks into +the review queue. `test/repo-hygiene.test.mjs` now fails if a second one +appears, so this cannot be rediscovered by a reviewer again. diff --git a/demo/stand/demo_guard/manifest.json b/demo/stand/demo_guard/manifest.template.json similarity index 100% rename from demo/stand/demo_guard/manifest.json rename to demo/stand/demo_guard/manifest.template.json diff --git a/demo/stand/demo_robot/manifest.json b/demo/stand/demo_robot/manifest.template.json similarity index 100% rename from demo/stand/demo_robot/manifest.json rename to demo/stand/demo_robot/manifest.template.json diff --git a/demo/stand/install.sh b/demo/stand/install.sh new file mode 100755 index 00000000..8b935d90 --- /dev/null +++ b/demo/stand/install.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +# Install the stand-only integrations into a Home Assistant config directory. +# +# ./install.sh /mnt/data/supervisor/homeassistant +# +# The manifests are stored as `manifest.template.json` on purpose: the HACS +# submission check globs `*manifest.json` over the whole clone of the default +# branch and refuses a repository with more than one of them +# (hacs/default, scripts/helpers/integration_path.py). See README.md. +set -eu + +target="${1:?usage: install.sh }" +here="$(cd "$(dirname "$0")" && pwd)" + +for comp in demo_robot demo_guard; do + src="$here/$comp" + [ -d "$src" ] || { echo "missing $src" >&2; exit 1; } + dst="$target/custom_components/$comp" + mkdir -p "$dst" + cp -a "$src/." "$dst/" + mv -f "$dst/manifest.template.json" "$dst/manifest.json" + echo "installed $comp -> $dst" +done diff --git a/test/golden-policy.test.mjs b/test/golden-policy.test.mjs index f0d93af1..de2dbb0f 100644 --- a/test/golden-policy.test.mjs +++ b/test/golden-policy.test.mjs @@ -7,8 +7,12 @@ import { } from '../demo/golden/policy.mjs'; test('golden metadata cannot be mistaken for a Home Assistant integration manifest', () => { - assert.equal(GOLDEN_BASELINE_MANIFEST, 'baseline-manifest.json'); - assert.notEqual(GOLDEN_BASELINE_MANIFEST, 'manifest.json'); + assert.equal(GOLDEN_BASELINE_MANIFEST, 'baselines-index.json'); + // The old check compared against 'manifest.json' and passed while the file + // was called 'baseline-manifest.json' — which is exactly what the HACS glob + // `*manifest.json` matches, and what turned Hassfest red on PR #9004 + // (2026-08-11). Ending with the word is enough to break the submission. + assert.equal(GOLDEN_BASELINE_MANIFEST.endsWith('manifest.json'), false); }); test('golden capture fails on runtime errors but permits missing baselines', () => { diff --git a/test/repo-hygiene.test.mjs b/test/repo-hygiene.test.mjs new file mode 100644 index 00000000..08178d2c --- /dev/null +++ b/test/repo-hygiene.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const SKIP = new Set(['.git', 'node_modules', 'dist', 'coverage', '.venv', '__pycache__', '.pytest_cache']); + +const walk = (dir, out = []) => { + for (const name of readdirSync(dir)) { + if (SKIP.has(name)) continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full, out); + else out.push(relative(ROOT, full).split(sep).join('/')); + } + return out; +}; + +// The HACS submission check does not read hacs.json to find the integration: it +// globs `*manifest.json` over the whole clone of the DEFAULT branch and exits 1 +// unless there is exactly one (hacs/default, scripts/helpers/integration_path.py +// -> "No manifest"). Two stand-only manifests under demo/stand turned the +// Hassfest job of PR #9004 red on 2026-08-11, five weeks into the review queue, +// and the failure said nothing about which file was to blame. Anything that +// needs a second manifest ships it as `manifest.template.json` and renames it at +// install time — see demo/stand/install.sh. +test('the tree carries exactly one *manifest.json: HACS rejects a repository with two', () => { + const found = walk(ROOT).filter((path) => path.split('/').pop().endsWith('manifest.json')); + assert.deepEqual(found, ['custom_components/houseplan/manifest.json']); +});