`ubuntu-latest` с 19.10.2026 переезжает на Ubuntu 26, а golden, скриншоты документации и перф-бюджеты сняты на текущем образе: все 43 job на раннере теперь явно на `ubuntu-24.04`, один образ на все workflow. 26 job получили `timeout-minutes` по наблюдённой длительности с запасом; гейт релиза — 180, больше суммы собственных ожиданий (60 + 60 + 45). Расписания ушли с круглых минут (ночь 02:17, мутанты 00:43, метрики 05:23, полный перф 04:11), ночь пишет в summary сдвиг старта и предупреждает, если он больше часа. test/workflow-hygiene.test.mjs держит все три правила по тексту workflow (разбор `parseJobSettings` в scripts/workflow-jobs.mjs) и исполняет шаг сдвига старта настоящим bash; порядок осознанного подъёма образа — docs/DEVELOPMENT.md. Issue: #658 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
45 KiB
Development and deployment
Input support contract
Read docs/TOUCH-SUPPORT.md before changing interaction code.
- View and kiosk must work well on touch and remain release-blocking surfaces.
- Editors are implemented and accepted against a desktop browser with mouse/keyboard first.
- Full editor parity on phones/tablets is not required. If correct touch support is expensive, an intentionally reduced or absent touch path is allowed.
- Every editor feature/spec/code review must classify touch as supported, best-effort/degraded, or not exposed.
- A degradation is valid only when documented in the same change. It may not compromise data integrity, permissions, confirmations or ordinary View.
- Do not add complex gesture state solely to claim touch parity. Prefer a clear desktop recommendation or safe unavailable action over unreliable editing.
Existing touch editor behaviour is not silently disposable: when changing a covered workflow, update its test and documentation explicitly and record why the degradation is accepted.
Environment (cowork sessions)
- The source of truth is GitHub
main(https://github.com/Matysh/houseplan-card). In a sandbox session restore from it or fromhouseplan-card.git.bundle(git clone houseplan-card.git.bundle hpcNinto a fresh /tmp directory). - The user's folder
houseplan/houseplan-card/is a file mirror (synced after every commit)- an up-to-date
houseplan-card.git.bundle. The mount cannot delete files — stale artifacts linger there; git is authoritative.
- an up-to-date
/tmppersists between sessions, but files created in previous sessions belong tonobodyand are unreadable (this hit/tmp/hpc,/tmp/ha_jb,/tmp/shots/srv). Always clone into a new directory and re-runnpm ci; ask the user to re-uploadha_jb.- Headless Chromium for smoke tests:
PLAYWRIGHT_BROWSERS_PATH=/tmp/pw npx playwright install chromium-headless-shell, then run withLD_LIBRARY_PATHpointing to the extracted lib dirs (libs/lib/x86_64-linux-gnu:libs/usr/lib/x86_64-linux-gnu:.../nss). - Restart HA over SSH with
nohup ha core restart >/dev/null 2>&1 </dev/null &— a plainha core restartholds the SSH session until the sandbox call times out. - GitHub pushes: classic PAT (repo+workflow scopes), created via the user's Chrome;
stored in
~/.git-credentialsfor the session.
Local contour in 5 minutes (локальный контур за 5 минут, #633)
Three commands take a fresh Linux sandbox (agent session, WSL, a clean VM) from nothing to a green smoke, a full unit run in parts and a pre-push gate. Each step fits the ≈3-minute limit of one sandbox command; everything is idempotent, so after a timeout or a sandbox restart the same command is simply repeated.
# 1. Worktree + dependencies + Chromium + bundle + one Playwright page.
# HP_BRANCH picks the branch (taken from origin if it exists there);
# without it the worktree is a detached origin/dev.
HP_BRANCH=issue/NNN-slug HP_WORKTREE=/tmp/w-NNN bash scripts/sandbox-bootstrap.sh
# or step by step: worktree | deps | chromium | bundle | check
cd /tmp/w-NNN && node demo/smoke_edge_cases.mjs # AC2 of #633: green
# 2. The full unit suite in parts that each fit one command.
npm run test:chunk -- 1/6 # builds test-build/, then the first sixth
npm run test:chunk -- 2/6 --no-build
npm run test:chunk -- 3/6 --list # only print the files of the part
# 3. Push: the pre-push hook runs npm run gate:small for issue/* branches.
git push origin issue/NNN-slug
HP_PREPUSH_GATE=0 git push origin issue/NNN-slug # explicit opt-out
What each command guarantees:
scripts/sandbox-bootstrap.sh— the worktree comes from the clone the script lives in (HP_CLONEoverrides),npm ci --ignore-scriptsruns only whenpackage-lock.jsonchanged (HP_SHARED_NODE_MODULESlinks a readynode_modulesinstead), Chromium comes from the npm package@sparticuz/chromium@152.0.0(the Playwright CDN is closed in the sandbox, the npm registry is not) and gets a shim at every path Playwright expects — a real browser already there is left alone.bundleisnpm run bundle:sync;checkopens one page in Playwright. The script carries no owner paths and no credentials: pushing is configured separately. Golden frames are still captured only in Linux CI (#455).npm run test:chunk -- N/M—test/*.test.mjssorted by code point, file i goes to part i mod M + 1 (round-robin). Chosen over size-balanced parts so that a file stays in the same part while tests are edited; the M parts together cover every file exactly once (test/test-chunk.test.mjs). The test build runs in every part unless--no-build, so a part is self-contained after a sandbox restart.- pre-push — see TESTING.md «Локальный набор перед пушем»:
on by default for
issue/*branches with an executable diff, skipped when the branch diff againstorigin/devis class C/D only (review documents, changelogs, bundle), off withHP_PREPUSH_GATE=0, forced for any branch withHP_PREPUSH_GATE=1.gate:smalltakes minutes; when a push must fit a 3-minute command, runnpm run gate:smallseparately and push withHP_PREPUSH_GATE=0.
Local Windows workstation
The CI contract is Node.js 22 + Python 3.14. Do not use Codex's bundled
Node 24 or an unpinned machine Python environment as proof that a release will
pass. The pins are not declared twice: node scripts/toolchain-pins.mjs reads
them from validate.yml, tests_backend/requirements.txt and the lockfile, and
npm run toolchain:check compares the machine with them (#496). .nvmrc and
.python-version carry the same values for nvm/uv/pyenv; a test keeps them equal.
The supported native setup is repository-scoped and does not change the
machine's default Node, Python or persistent PATH (#557):
# One-time/idempotent setup. Requires uv; installs a verified portable Node 22
# under %LOCALAPPDATA% and Python 3.14 in the dedicated .venv-ci.
.\scripts\windows-toolchain.ps1 setup
# Read-only proof: actual versions and executable/package/browser paths.
.\scripts\windows-toolchain.ps1 check
# Explicit pinned entrypoints for ordinary commands; no accidental PATH tools.
.\scripts\windows-toolchain.ps1 npm run gate:small
.\scripts\windows-toolchain.ps1 python -Arguments @(
'-m', 'pytest', '-p', 'pytest_asyncio.plugin',
'tests_backend/test_validation.py', 'tests_backend/test_trails.py',
'tests_backend/test_trail_recorder.py', '-q'
)
.\scripts\windows-toolchain.ps1 playwright install chromium
The Node archive is selected from the official release index for the major in
.nvmrc and checked against Node's SHASUMS256.txt. The script prepends that
directory to PATH only for its child process. It never removes an existing
venv: if the requested -VenvPath contains another Python minor, setup stops
and asks for another path. Playwright remains in its normal shared Windows
cache. Install uv once with winget install --id astral-sh.uv --source winget
if it is absent; GitHub access still uses the separately installed gh.
WSL2 is optional for the ordinary frontend and pure-backend loop. Keep its clone
inside Linux ext4, not under /mnt/c; on a fresh checkout run:
cd ~/houseplan-card
bash scripts/wsl-setup.sh # idempotent setup in dedicated .venv-ci
bash scripts/wsl-setup.sh --check # no installation; paths + versions only
bash scripts/wsl-setup.sh --verify # setup, real HA subset and one golden capture
The script provisions the CI pins (nvm → Node, uv → Python and the HA test stack
from tests_backend/requirements.txt, Playwright Chromium from the lockfile).
--verify imports Unix-only fcntl and the pinned Home Assistant, runs
tests_backend/test_ha_setup.py, builds the card and captures
panel-wide-view-light-en under artifacts/golden/; it records elapsed time and
the resulting PNG path. HOUSEPLAN_VENV selects another dedicated venv without
deleting or rewriting an existing one. WSL is normally early feedback. For an
intentional baseline change it may also produce the reviewed candidate with the
fail-closed command below; the canonical merge/release proof still lives in
GitHub Linux CI at the final exact SHA.
It is required only when running the full HA harness locally: current Home
Assistant imports the Unix-only fcntl module and cannot start its pytest plugin
on native Windows. Keep a WSL clone inside the Linux ext4 filesystem rather than
under /mnt/c, otherwise dependency installs become slower. The release CI
always runs this harness on Ubuntu and gates the exact tagged commit. Docker
Desktop is not currently required. Do not install the full Home Assistant pytest
stack natively just for this repository: its pinned lru-dict==1.3.0 first
requires Visual Studio Build Tools to compile, but the resulting plugin still
cannot run without fcntl.
Useful repo-local Git settings on NTFS (optional for this small repository):
git config core.fsmonitor true
git config core.untrackedCache true
⚠️ File-sync pitfalls (critical)
- The network mount sometimes serves files truncated/scrambled — edits via the Edit tool from the Windows side are unreliable. Rule: apply python patches against a clean copy in /tmp, write via bash, with an assert that count(old)==1.
- Run the rollup build ONLY in /tmp/hpc (
npm ciis already done). A build on the mount once produced a syntactically valid but broken bundle ("wi is not defined") that crashed the rendering of ALL HA dashboards (the card is loaded as an extra_module on every page!). .gitcannot be created on the mount ("Operation not permitted" on dot-directories) — hence the bundle.
Local repository maintenance (#628)
Owner decision on 2026-09-25: use only a one-time local garbage collection for
the Windows clone. This issue does not introduce Git LFS, stop tracking
dist/**, remove the committed integration bundle, or rewrite published Git
history.
Run the maintenance command only when no Git process is active in the shared clone (all worktrees use the same object database):
git gc --prune=now
git count-objects -vH
git fsck --no-dangling --no-progress
The 2026-09-25 measurement before GC was 26,243 loose objects / 749.84 MiB,
41 packs / 334.55 MiB and 98 garbage entries / 959.60 KiB. After GC it is zero
loose objects, two packs / 467.63 MiB, zero tmp_obj_* files, and a clean
git fsck. size-pack grew because reachable loose objects moved into packs;
the meaningful total (size + size-pack) fell from 1,084.39 MiB to
467.63 MiB, a reduction of 616.76 MiB.
This post-GC value is the #628 monthly-growth baseline. To evaluate AC2 on or
after 2026-10-25, run the same GC and count-objects sequence and compare the
new size-pack with 467.63 MiB; the accepted upper bound is 487.63 MiB.
Tests
- Frontend:
npm test— compiles src/logic.ts+rules.ts (tsconfig.test.json) and runs node:test (test/*.test.mjs). Strict typing:npm run typecheck(tsc --noEmit, part ofnpm run build). - Pure backend on native Windows (with no HA plugin autoload): use the explicit
python -Arguments @(...)invocation above after setting$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'. - Full backend (including
test_ha_*.py):python -m pytest tests_backend/ -qin CI or WSL/Linux only. - Junction-limit TS/Python parity (clean, no Home Assistant):
npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs && python tests_backend/junction_parity.py --build-dir=test-build/junction-parity. Its dedicated reusable Validate job owns this proof; missing compiled modules fail setup instead of turning into a pytest skip. - IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still
builds the bundle — a truncated file can "pass". That is why the build starts with
tsc --noEmit, which fails on such errors. Always build withnpm run build, never barerollup -c. - The committed bundle changes only in a beta/release candidate (#657). Rollup writes
dist/houseplan-card.js,dist/houseplan-assets.jsonand content-hashed chunks underdist/houseplan-assets/;npm run bundle:syncbuilds and lays that tree out into the untracked demo copy, andnpm run bundle:cleanrestores the trackeddist/before an ordinary commit (thecommit-msghook refuses bundle paths without aRelease:trailer). The candidate runsnpm run bundle:release, which also updates the integration snapshotcustom_components/houseplan/frontend.node scripts/bundle-policy.mjs --verify HEADis the check CI andgate:smallrun: build integrity always, byte parity with the committed copy only on a commit that changes the bundle or is a candidate;node scripts/bundle-tree.mjs dist custom_components/houseplan/frontendstays the read-only parity check of release automation. - The first-space/import dialog is a separate
houseplan-onboarding-runtime-*chunk. Do not fold it intohouseplan-editor-runtime-*: empty-install onboarding is a View prerequisite, while a configured View must request neither lazy runtime until the corresponding user intent. - 2.5D rendering is a separate
iso-scene-render-*chunk. A View withsettings.volumetric_viewoff (#649) must not request it. Its normal import and content-hashed retry must pass the same source-fingerprint handshake before Iso installs atomically;houseplan-assets.jsonrecords the graph aslazyIsometricFiles.
Maintenance diagnostics
Labs presentation flags
Labs is an internal, presentation-only runtime in src/labs.ts; it must never
gate config/schema migrations, persistence writes, HA services or network
requests. ?hp_alpha=1 or #hp_alpha=1&space=<id> enables every experimental
capability in the current build and persists 1 in
houseplan_card_alpha_v1; hp_alpha=0 disables them and persists 0. Query is
applied before hash and the last exact 1/0 wins. The URL is not rewritten,
unknown values fail closed for the current resolution, and the legacy
hp-labs/houseplan_card_labs_v1 inputs are not read or migrated. Diagnostics
expose the boolean window.__hpAlpha together with the frozen sorted
window.__hpLabs capability array. Since #649 the registry is empty: 2.5D left
alpha for the General settings switch settings.volumetric_view; smokes turn it
on with window.__hpTest.setVolumetricView(true).
To add a capability, add one unique lowercase id plus issue and a non-empty
summary to LABS_FLAGS, then cover registry validation and the alpha-on active
set. Capabilities have no individual public key or version lifetime: the one
persisted alpha switch is deliberately indefinite until the owner changes the
contract. See docs/ISOMETRIC.md for the current use.
These commands are read-only diagnostics, not release gates:
# Show registered legacy/internal fields or inspect an exported config locally.
npm run audit:config
npm run audit:config -- path/to/houseplan-config.json
# Reproducible synthetic large-house report (seven measured samples + warm-up).
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
# Hidden isometric profile; diagnostic only outside exact-SHA Linux CI.
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
# Dense Stage 3 overlay/opening profile; also diagnostic outside exact-SHA Linux CI.
npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-stage3-local.json
# Golden candidates never overwrite reviewed references.
npm run golden:capture
npm run golden:verify
npm run golden:accept -- --reviewed
# Full attested candidate for baseline acceptance. Run only in the ext4 WSL
# clone, on a clean named branch whose HEAD already equals origin/<branch>.
npm run golden:wsl:capture -- --expect-change=<scene-id,scene-id>
npm run golden:accept -- --reviewed --from=artifacts/golden \
--expect-change=<scene-id,scene-id>
# Docs screenshots whose pixels did not change (a version bump, a refactor):
# re-capture locally, compare decoded RGBA against the committed frames and,
# if every frame is identical, refresh only the manifest fingerprints (#512).
npm run docs:accept -- --identical
Golden frames never show the real card version: the harness sets the test-only
seam window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden' before the card is
created, so a version bump alone changes no baseline (#512, see
demo/golden/README.md).
golden:wsl:capture refuses native Windows, /mnt/c, dirty or detached trees,
unpublished/mismatched branch SHAs, stale source fingerprints, toolchain drift,
partial matrices, undeclared differences and an insufficient witness floor. It
writes artifacts/golden/wsl-attestation.json, which self-hashes the source
identity, environment, pinned toolchain, report, every PNG and the acceptance
intent. Acceptance verifies the passport again and records it under
localAttestation in the baseline index. Copy the printed
Baseline-Reviewed-Local: sha256:… line to the baseline commit together with
Release:; never add the GitHub Baseline-Reviewed: trailer to the same commit.
Push that commit and wait for the full GitHub Validate on its exact SHA before
S7/merge. The local path removes the earlier expected-red capture run, not this
independent final check. A downloaded golden-images artifact from GitHub stays
supported and keeps the existing Baseline-Reviewed: <run URL> provenance.
The config audit performs no network requests and does not rewrite the input.
Its registry and lifecycle rules are documented in CONFIG-COMPATIBILITY.md.
The blocking performance workflow runs its independent profile pairs in
parallel. Inside each pair it captures the base SHA and candidate sequentially
on one pinned Chromium/CI runner, applies the same relative and absolute budget,
and uploads both reports plus the comparison. This preserves same-machine
comparability without serialising the whole matrix beyond the job timeout. A
developer-laptop report remains a diagnostic and must not be used to loosen CI
limits. See
demo/performance/README.md.
When the comparison base predates scripts/bundle-sync.mjs, the workflow still
builds that exact tree and materializes its fresh bundle through the equivalent
legacy copy path. This keeps old stable releases usable as performance baselines
without borrowing build output from the candidate. Comparative benchmark
launches also pass that target tree as the freshness authority; the ordinary
smoke launcher continues to default to the current repository root.
Both browser diagnostics require a freshly built/copied demo bundle. Rollup
embeds a SHA-256 fingerprint of src/ plus the locked package and
Rollup/TypeScript build inputs; benchmark/golden runners fail before
capturing anything when demo/srv/assets/houseplan-card.js is stale. Golden
commands and the explicit review workflow are documented in
demo/golden/README.md.
Build
cd /tmp/hpc && npm ci # once
npm run bundle:sync # build + entry/manifest/chunks → demo
npm run bundle:budget # initial View graph within INITIAL_VIEW_GZIP_BUDGET (scripts/bundle-budget.mjs)
npm run bundle:clean # before an ordinary commit (#657)
npm run bundle:release # candidate only: also → custom_components/houseplan/frontend
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend # candidate parity
Deployment to the dacha (ha.jbstudio.pro)
- SSH: port 22222, root, key
ha_jb(lives in the user folderhouseplan/.secrets/ha_jb, outside git; copy into the sandbox with chmod 600 — only ask the user if it is gone). - The HA config root is
/mnt/data/supervisor/homeassistant— in this SSH environment/configdoes not exist; a deploy aimed at/config/...fails with "No such file or directory". - Frontend: copy the complete
custom_components/houseplan/frontend/tree. Copying onlyhouseplan-card.jsis unsupported: the entry imports hashed chunks and validates its editor runtime against the build fingerprint. - Cache busting:
sedthe?v=version in.storage/lovelace_resources, then restart HA. - The
frontend/subfolder is not optional.__init__.pyregistersPath(__file__).parent / "frontend" / "houseplan-card.js"as the static path. A copy dropped next to__init__.py(…/houseplan/houseplan-card.js) is served by nobody: md5 on the server matches, the browser still gets the old bundle, and hours go into debugging a bug that was already fixed. Cost this mistake once: 2026-07-27, two releases deployed into the void. - The whole integration: tar c custom_components/houseplan (--exclude pycache) → tar x on the server.
- Verification is mandatory, and it must go over HTTP — comparing md5 against
the file you just copied proves nothing about what the browser receives. The
one check that counts:
curl -s https://ha.jbstudio.pro/houseplan_files/houseplan-card.js | grep -o '1\.[0-9]*\.[0-9]*' | sort -umust print the version just built. (Inside the SSH add-onlocalhostis NOT HA — use the hosthomeassistant.) - Python changes require an HA restart (
ha core restart, holds the connection until it finishes, HTTP comes back up in 1–3 min). JS changes — just a page refresh (the static path is served with no-cache). - After deploying JS — check in the browser (Ctrl+F5) and the console (there must be no errors from houseplan-card.js; a broken bundle takes down all dashboards).
Frontend cache and the "empty view"
- The card module URL contains
?v=<VERSION from const.py>. Browsers keep the ES module in memory cache: after deploying new JS bump VERSION in const.py and restart HA, otherwise a plain F5 will keep the old version. - After a page reload the HA frontend (with kiosk-mode) sometimes leaves the view empty ("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min). Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
Resource registration and version recovery (#462)
-
A writable Lovelace resource registry is the loader authority. Registration returns a typed outcome; a pending or transient first attempt installs the
extra_module_urlfallback immediately and schedules exactly one retry after Home Assistant reaches running state plus a fixed one-second delay. Listener, timer and retry task must all be cancelled through config-entry unload. -
Runtime registration facts belong in
hass.data[DOMAIN]and System Health, not in plan/layout storage. The persistent hard-reload notification is localized from the backendissuestranslation category and its config-entry flag is written only afterpersistent_notification.async_createreturns without an exception. -
Every successful
houseplan/config/getis authoritative forintegration_version. A missing, non-string or whitespace-only value clears a previously known version. The full-card version controller stays in the initial View graph; do not move it behind the lazy editor runtime or add it tohouseplan-space-card. -
Targeted checks while changing this contract are:
python -m pytest tests_backend/test_ha_frontend_registration.py tests_backend/test_ha_setup.py -q npx tsc -p tsconfig.test.json node scripts/fix-test-build.mjs node --test test/version-recovery.test.mjs node demo/smoke_version_recovery.mjs node scripts/check-docs.mjsThe full HA pytest harness requires Linux/WSL; native Windows lacks
fcntl.
The stylesheet minifier sees TypeScript output, not the source (#526)
scripts/css-template-minifier.mjs runs as a Rollup transform, and by then the
module has already been through TypeScript. The TS printer puts a space between
a tag and its template, so the source css… arrives as `css `….
The plugin used to look for the exact string css + backtick and therefore
returned null for every stylesheet in the project: minification never ran
once, and roughly 23 KB of explanatory comments were shipped to every user —
12.8 KB gzipped in the initial chunk.
Two consequences for anyone touching this area:
- match the tag as a word followed by optional whitespace, never as a literal two-character string;
- the guard that keeps this honest is not inside the plugin but in
test/bundle-assets.test.mjs: it takes real comment text out ofsrc/styles/*.tsand asserts none of it appears indist/**. A plugin that silently stops working cannot pass it.
Do not animate container-relative properties on the plan (#524)
A CSS property whose value is expressed in container query units — cqw,
cqh, or any custom property derived from them, such as --dev-size — must
not appear in a transition on elements the plan draws in quantity.
Container query styles are re-evaluated whenever the container's inline size changes: a tooltip, a scrollbar, a rotation, a panel resize. Every such re-evaluation produces a new computed value and therefore restarts the transition on every one of those elements at once.
That is how box-shadow on device markers cost a real user 9.4 frames per
second in Firefox 155: sixty-one markers started a 150 ms non-composited
shadow transition four times in two seconds, and the refresh driver spent the
window waiting for paint. Chromium starts exactly the same transitions — it
merely pays less for them, which is why the defect hid there.
The witness is browser-independent and lives in
demo/smoke_marker_shadow_transitions.mjs: change the stage container width by
one pixel and assert that no transitionrun for box-shadow arrives.
Updating a pinned Action (#556)
Every uses: in .github/workflows/** is a full commit SHA with the human
version in a trailing comment; node scripts/action-pins.mjs enforces it and the
Validate preflight runs it. The comment is not decoration — it is the only thing
that tells a reader which release they audited.
To move a pin: read what the tag points at today,
gh api repos/<owner>/<repo>/commits/<tag> -q .sha
read the delta from the currently pinned SHA, then change both the SHA and
the comment in one commit. node scripts/action-pins.mjs --list prints every
third-party action with its pin, which is the fastest way to see what is behind.
Two of these are branches upstream, not releases — home-assistant/actions
(master) and hacs/action (main) — so their comment carries the date the
branch head was read. They have no tags to follow; the only honest record is
"this commit, read on this day".
Moving the runner image (#658)
Every job that runs on a GitHub runner names an explicit image version in
runs-on:, and all workflows name the same one; test/workflow-hygiene.test.mjs
rejects a floating label such as ubuntu-latest and any drift between files.
The reason is the evidence tied to the image: golden baselines, documentation
screenshots and performance budgets were captured on it with the pinned
Playwright/Chromium (#455, #557), and Chromium's system libraries come from the
image itself (the install steps deliberately skip --with-deps). A floating
label moves under that evidence — ubuntu-latest becomes Ubuntu 26 from
2026-10-19 (actions/runner-images#14748) — and would turn a day with no change
into mass golden different and a red performance smoke.
Move the image on purpose, as its own infra issue:
- change
runs-on:in every workflow in one commit; - capture golden in CI on the new image and accept the differences with
review (
npm run golden:accept -- --reviewed …), re-capture the documentation screenshots (demo/docs/capture.mjs); - run the full Validate and
performance.yml, and recalibrate a budget only with measured evidence next to the number (the #483 and #675 pattern); - mirror the thin callers into
main(seeworkflow_syncinvalidate.yml) and remember thatrelease.yml,performance.ymland the other filesmainexecutes pick the new image up only with the next stable promotion.
The same test requires timeout-minutes on every runner job (at most 180; the
default is 360) and keeps cron off minutes 0/15/30/45, which GitHub's
scheduler delays by hours. A job that waits for other runs, such as the release
gate, gets a timeout above the sum of its own waits and says so in a comment.
What the review model is allowed to do (#556)
The model_review job is the only untrusted stage of the review pipeline: it
runs a model with Read/Write/Bash over the material. Its permissions: block
is the real ceiling only because the Review step is handed
github_token: ${{ secrets.GITHUB_TOKEN }}.
Without that input claude-code-action exchanges the job's OIDC token for its
own GitHub App installation token (src/github/token.ts), and that exchange
defaults to contents: write, pull_requests: write, issues: write no matter
what the calling job declared — the ghs_… token then sits in the environment of
the model's own Bash tool. Removing the one line therefore widens the model's
rights silently, with every test still green, which is why there is a witness
(test/review-doc-guard.test.mjs) and a mutant
(review-job-trusts-the-app-token) standing on it.
issues: write is the single write scope the model keeps, because the process
asks the reviewer for the verdict comment (§7.2) and a separate issue for
out-of-scope Medium findings (§12). It buys comments, labels and issue-body
edits — not a commit, not a merge (that is decided in integrate from the sealed
verdict.json), not a release. Dropping it means moving both duties into
integrate, which is a pipeline change and not part of this one.
Dependency and cache gotchas
- polygon-clipping is a trap: its
.d.tsdeclares named exports but the ESM build has only a default export — tsc or the runtime breaks, whichever you appease. Use polyclip-ts (proper ESM + native types; same results, +~50 KB bundle via bignumber.js). - Redeploying the same version keeps the resource URL (
/houseplan_files/houseplan-card.js?v=X), so browsers may serve the previous bundle from cache. Bump the version for anything users must pick up, or hard-refresh (Ctrl+Shift+R) when testing a hotfix redeploy. - CSS
filter: blur()on an SVG group is applied in name only in Chromium:getComputedStylereturnsblur(1px), and the rendered result changes by a couple of hundred pixels on a whole plan — i.e. not at all. Use an SVG<filter>withfeGaussianBlurandfilterUnits="userSpaceOnUse". One filter over the light layer costs about a fifth of one blurred mask per source (60 sources: 66 ms vs 206 ms). - A geometry cache must be keyed by the geometry, not by
_cfgEpoch. The epoch lags behind edits made in place (boundary/opening tools mutate the space object), and a stale barrier set is invisible: the plan keeps lighting through a wall that already exists._lightBarriershashes its own inputs instead, and the same fingerprint keys the per-source region cache. - Segments that cross must be split before a visibility sweep. The sweep
casts a ray at every barrier ENDPOINT; two faces crossing in their middles —
normal where wall bodies meet at a junction — leave that corner unsampled and
the fan closes it with a chord, so a sliver of floor next to a corner the
lamp plainly sees goes dark.
splitAtIntersectionsremoves the whole class.
Release
Primary prerelease path
Prepare the candidate as usual: synchronize every version field, add dated RU
and EN changelog sections, update the production bundle snapshots with
npm run bundle:release (since #657 the only commit that may change them; it
carries the Release: trailer) and write the
short bilingual body in docs/RELEASE-NOTES.md. That file is the one current
instance of the canonical ## Основное / ## Highlights template; its two
changelog links must be pinned to the new tag.
After the candidate commit is pushed to dev and its exact-SHA Validate is
green, publication is one command:
npm run release:prerelease -- v1.61.0-beta.4 --issues=63,64 --yes
Omit --yes for an interactive tag confirmation. Use the same fail-closed
preflight without creating a tag or release with:
npm run release:check -- v1.61.0-beta.4 --issues=63,64
The orchestrator requires a clean, synchronized dev, byte-identical bundle
snapshots and a completed green Validate for HEAD. Snapshot hashes and the
uploaded standalone JS are read from the exact Git blobs rather than checkout
bytes, so Windows CRLF conversion cannot disagree with the LF-tagged archive.
The archive command additionally forces core.autocrlf=false for that one
operation; it does not modify the developer's Git configuration.
It creates or verifies an
annotated exact-SHA tag, builds houseplan.zip directly from that committed
tree, verifies its manifest and embedded frontend against the candidate hash,
and creates RELEASE-MEMBERSHIP.json. Every explicitly supplied issue must have
an Issue: #NN trailer in the candidate history since the previous release;
issue authorship is irrelevant. The prerelease is staged as a draft and receives
both installable assets, the membership manifest and their SHA256SUMS passport.
Only then does it become public. The downloaded public bytes and membership are
verified against the candidate, followed by the paginated HACS order and
manifest-driven issue bookkeeping. Nothing else re-uploads assets after
publication (#540): the bytes it verified are the bytes that stay. Re-running
the same command after a partial failure is safe when the checkout still points
to the tagged candidate: stale assets are repaired, while a hidden per-release
comment marker, manifest membership and postcondition check make comment,
status-label removal and close individually repeatable (#547). ZIP inspection
is implemented in Node and does not depend on the host's tar/unzip variant.
A per-tag local lock and GitHub workflow
concurrency reject parallel runs; the local lock is removed on normal exit and
on handled SIGHUP/SIGINT/SIGTERM interruption (SIGKILL cannot be handled
by any process).
Publish prerelease in GitHub Actions is the browser/button equivalent. Select
the dev branch and enter the exact tag. It performs the same contract and
draft-first publication entirely on GitHub, including both assets. Prereleases
are intentionally silent in Telegram; only stable releases are announced.
GitHub exposes a workflow_dispatch button only after
the workflow file exists on the default branch; until the next promotion to
main, use the local command. The workflow snapshots the current S8 candidates
before publication, then retains only issues proven by Git history in the pinned
SHA. Its close-merged job consumes that immutable manifest after public asset
verification, so work merged later remains open and an accepted external issue
is treated like an owner-authored one. A retry reuses the published manifest and
resumes bookkeeping even when the release is already public (#120, #547).
Independent line review (#638, PROCESS.md §11.5). Right after the
candidate SHA is pinned, the independent-review job of release.yml queues
.github/workflows/release-review.yml on dev for the same tag and SHA. It
reviews every product surface changed since the previous stable tag against
docs/SCOPE.md and docs/USER-GUIDE.ru.md — without specs or review rounds —
and publishes docs/reviews/RELEASE-REVIEW-vX.Y.Z.md to dev. It runs in
parallel and never blocks the release: no release job needs it, a failure
is a warning, and the findings are the owner's call. Manual run:
gh workflow run release-review.yml --ref dev -f tag=vX.Y.Z; a repeat for a
tag whose document already exists is skipped unless -f force=true.
Stable releases go through .github/workflows/release.yml, the only
publisher of installable assets (#540). Run it with workflow_dispatch on
main with the exact tag: when the tag does not exist yet it is created on the
main tip; when it exists, its commit is the candidate. The workflow resolves
the tag to its exact commit, requires the Release: <tag> trailer on it,
checks the release contract (release-contract.mjs --stable) and
requires a complete Validate proof for its SHA and
Git tree (#541). The proof is tied to the workflow run ID and attempt and lists
both the requested checks and the jobs that actually executed. A skipped heavy job counts only
when its content-addressed reuse marker names an independently verified
successful source job. Since #573 the proof also carries composite evidence:
the identity of the product tree (every tracked path except the accepted
golden overlay demo/golden/baselines/**), the overlay itself (its Git tree,
the SHA-256 of baselines-index.json and either the run named by the commit's
Baseline-Reviewed: trailer or the attestation hash stored by a
Baseline-Reviewed-Local: commit) and the content key of every reusable job,
executed or reused. Release consumers standing on the candidate checkout
(release-gate.mjs, release-prerelease.mjs) recompute all of it locally and
fail closed on any mismatch, on a reused marker whose key is not the
candidate's, and on a declared review run that does not exist, was cancelled
or is not a Validate run; a proof without the block is stale for them. Review
and merge consumers pass no expectations, do not query the declared review run
and keep the #541 semantics unchanged. The
practical consequence is the beta.3 path: a candidate red only in golden,
then a baseline-only commit that reuses smoke, performance smoke, parity and
backend from the candidate's green jobs, skips every caught witness in the
mutation ledger and re-runs golden, preflight and frontend only. Review, merge and release use the
same missing / pending / cancelled / stale / failed state machine. A
cancelled or light run is not a release verdict. The newest compatible full run is the verdict:
a later failed full run blocks an older green proof, while a later cancelled,
light or otherwise stale run is skipped because it does not answer the same
release policy. A later complete green full run can refresh an older failure
(#511, #619, #656). The release also requires Full
Performance and a green E2E run on a
real Home Assistant — e2e-gate.mjs --ref=<sha> dispatches e2e.yml in
Matysh/houseplan-e2e on the candidate commit, whose
custom_components/houseplan tree the suite installs from the codeload tarball
(#514, #540) — then builds once, archives houseplan.zip from that same tree
(git archive <sha>:custom_components/houseplan, deterministic), writes
SHA256SUMS, uploads everything into a draft, publishes, downloads the public
assets back and checks them against the passport, and only then announces. The
tree hash printed in the run summary is the identity between what E2E installed
and what HACS downloads.
Publishing a stable release by hand in the GitHub form still works, but
fail-closed: release: published starts the same workflow, which immediately
turns the release back into a draft and walks the same path; nothing installable
is public while the gates run. A red gate leaves the draft in place — open the
linked run, the Playwright traces and screenshots are in its artifacts; fix,
then cut a new tag. Re-dispatching the workflow on an already public tag is a
repair: the gates run again on the SHA, missing assets are added, and an
existing asset whose hash differs from the rebuilt one fails the run instead of
being replaced. Hand-published betas are ignored by this workflow — prereleases
have their own staged path above. Bump the version
everywhere in sync: src/houseplan-card.ts (CARD_VERSION), package.json,
custom_components/houseplan/manifest.json, custom_components/houseplan/const.py.
Validate intentionally runs on branch pushes, not tag pushes, so an annotated
release tag does not duplicate the expensive browser/performance matrix. Every
tagged SHA must therefore already be pushed to a branch and have a completed
green full exact-SHA Validate proof. For an owner-approved emergency hotfix, push a
temporary hotfix/* branch and wait for Validate before creating the tag;
never tag a detached or otherwise unpushed commit, because the release gate
will wait for a run that cannot exist and then fail closed after one hour.
The GitHub Release body is a concise bilingual user summary, not a copy of
the exhaustive changelog. Put Russian first and English second, with equivalent
meaning in both sections. Give separate bullets only to significant features
and user-visible behaviour changes. Collapse minor fixes, visual polish,
refactors, tests and purely internal improvements into one final bullet:
Мелкие исправления и улучшения. / Small fixes and improvements. Keep the
body short because HACS displays it inside Home Assistant and concatenates the
bodies of skipped releases. The full detail remains in both
docs/CHANGELOG.ru.md and docs/CHANGELOG.md; finish every release body with
two explicit links, one to each language version of the changelog.
Changelog entries may link directly to a closed GitHub Issue when that
issue is the canonical task for the shipped change. Append a normal Markdown
link such as ([#55](https://github.com/Matysh/houseplan-card/issues/55)) to
the relevant bullet in both language changelogs. Keep this optional: do not
invent issues for minor work, do not link open or partially delivered issues,
and do not expand the grouped small-fixes bullet into an issue inventory.
<!-- release: vX.Y.Z -->
## Основное
- Значимое изменение.
- Исправлена конкретная проблема ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Мелкие исправления и улучшения.
## Highlights
- Significant change.
- Fixed a specific problem ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/vX.Y.Z/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/vX.Y.Z/docs/CHANGELOG.md)
The literal ## Основное and ## Highlights headings above are the canonical
release-body format. Do not maintain a second ## Русский / ## English
template in scripts or release notes; change this single template if the
product format changes again.
Replace vX.Y.Z with the release tag so the links remain pinned to the
published version instead of drifting with dev or main.
Local release gates are deliberately different. A pre-release runs
npm run build plus only the unit tests and browser smokes selected for the
changed surfaces; record the exact selection in the release handoff. A stable
release runs the complete local frontend, backend and smoke gates before its
tag is created. The exact-SHA Validate required by release.yml remains in
force for both and can run a broader matrix automatically; the local policy
does not weaken the publication guard.
Feature promotion has an additional hard gate: every new feature or material behaviour change must spend at least one published beta/RC before stable. A stable release commit may change only version fields, generated bundle snapshots and changelog/release metadata; feature source changes belong in the preceding pre-release commit. Skip this step only for an explicit owner-approved emergency hotfix, and document the exception in the handoff.
Reproducible scripts (data)
- Extracting the geometry/backgrounds from the prototype and generating
src/data/*— see the commit history and docs/ARCHITECTURE.md (SVG→base-space transforms: f1 0.647/(490,27), f2 0.896/(351,21)). - Room fitting: render the plan with rectangles overlaid (cv2) → snap to walls → manual fine-tuning.
Production objects in HA (the dacha)
- Dashboard
plan-doma, panel view, cardcustom:houseplan-card(icon_size 2.5). - The houseplan integration: entry loaded,
.storage/houseplan.layout— the layout (server-side). - The old prototype
/config/www/houseplan/(iframe) is kept as a fallback, do not touch. - configuration.yaml backups:
.bak-avgtemp(before the average-temperature sensor edit).
Smoke tests (since 2026-07-27)
Every demo/smoke_*.mjs ends with:
checkAll(out); // every key must be true...
checkAll(out, { n: 4 }); // ...unless an expected value is given
await finish(browser, out);
finish prints the JSON dump (useful on failure), reports named mismatches and
sets a non-zero exit code — including when the card threw during the run. The
suite runs in CI (smoke job) against a freshly built bundle; never test the
demo/srv/assets/houseplan-card.js copy, which npm run bundle:sync writes and which is not committed (#255).
updateComplete proves only that Lit finished its own update. Pointer-owned
editor state can be painted later by live-editor, outside that cycle. Browser
checks which read live editor DOM must await the lazy runtime's
_whenLiveEditorSettled() contract; a timeout, sleep or fixed number of RAFs is
not evidence that the latest projection was applied. If a smoke changes editor
mode before dispatching synthetic gestures, it must also wait for the observable
mode-transition and viewport-refit state to settle, because the stage can finish
its physical resize after the Lit update (#460).
When adding a checklist line marked [auto: ...] in docs/TESTING.md or its
appendices in docs/testing-notes/, add the failing check in the same commit —
that is what the marker now promises.