chore: add hassio-addon-workflow skill for Claude Code (#2939)

* chore: add hassio-addon-workflow skill for Claude Code

Checks in the repo-specific skill so future Claude Code sessions get the
tiered scope->measure->plan->implement->verify workflow, repo traps, and
helper scripts (preflight/measure/env_trace/validate/pr_review) without
depending on a local machine's ~/.claude config.

* fix(skill): address PR review feedback from Copilot and Codex

- SKILL.md: repo-relative script invocation (skill is now checked in);
  correct the CI-gates list — the PR add-on linter is blocking, only the
  weekly Super-Linter is non-blocking
- preflight.sh: git-aware repo detection (worktrees have a .git file)
- pr_review.sh: header now documents resolve's actual --all behavior
- measure.sh: CPU% uses getconf CLK_TCK; sample all processes, not the
  top-24 by RSS
- env_trace.sh: validate VAR as a strict env-var name before regex use
- validate.sh: shellcheck also covers extensionless run/finish; --vs-master
  skips visibly when a linter is missing instead of reporting a false clean

* fix(skill): address CodeRabbit review feedback

- pr_review.sh: resolve exits nonzero unless every thread actually
  resolved; watch exits nonzero and says so when checks settle with
  failures instead of reporting bare "settled"
- validate.sh: pass the config.yaml path to Python as argv instead of
  interpolating $ADDON into the source (CWE-94)
This commit is contained in:
Alexandre
2026-08-04 22:29:20 +02:00
committed by GitHub
parent 7dfeb78c37
commit 74456a03b6
7 changed files with 1138 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
---
name: hassio-addon-workflow
description: >-
End-to-end workflow for alexbelgium/hassio-addons add-on work — scope the change, diagnose
against the live add-on with real measurements, get an independent Codex (gpt-5.6-sol) review
of the plan, implement, have Codex review the code adversarially, open a PR, resolve the
CodeRabbit / Copilot / Codex-connector review comments, and verify the merged result actually
works. Use this whenever the task touches a Home Assistant add-on in this repo — fixing a bug
or reported issue, tuning RAM, CPU or performance, editing a Dockerfile, config.yaml,
cont-init.d script or s6 service, bumping an add-on version, or opening and iterating a PR
against hassio-addons. Also use it when asked to "check with codex", "verify with chatgpt", or
to resolve bot review comments on an add-on PR. Small tasks (typo fixes, version bumps,
one-file tweaks, simple coding questions) route through a light path that skips measurement
and Codex reviews — invoking this skill is cheap for small asks too.
---
# Home Assistant add-on workflow
Triage first, then one of two paths:
- **Light path** (small, contained tasks): scope → implement the simplest mechanism →
validate → PR → resolve bot comments → report honestly.
- **Full loop** (diagnosis, performance, defaults, new mechanisms): scope → measure → plan →
Codex attacks the plan → implement → simplify → Codex attacks the code → PR → resolve bot
comments → verify in production → report with calibrated confidence.
**The standing rule on both paths:** when a simple solution and a more efficient-but-more-complex
one both work, ship the simple one. Complexity is only paid for by a **measurement** showing the
simple version has a concrete, user-visible cost on a real host — never by reasoning about
hypothetical performance. Slightly less efficient and obviously correct beats faster and harder
to review.
The disciplines below exist because each one, when skipped, produced a specific failure in this
repo — the examples throughout are real, not illustrative.
| Discipline | The failure it prevents |
|---|---|
| **Triage before you start** | Full ceremony spent on a one-line fix |
| **Scope before you work** | Solving the wrong problem, elegantly |
| **Evidence before reasoning** | Confident claims that measurement contradicts |
| **Reason adversarially** | Shipping something that only works on your host |
| **Simplify to the smallest thing that works** | 500 lines of machinery where an option would do |
| **Verify before declaring done** | "This should work" — and it doesn't |
| **Calibrate and report** | Verified and assumed presented as the same thing |
**Where things are.** The repo is `alexbelgium/hassio-addons`; each add-on is a top-level
directory (`claude_desktop/`, `birdnet-go/`, …). This skill and its scripts are checked into
the repo at `.claude/skills/hassio-addon-workflow/` — invoke scripts from the repo root, e.g.
`bash "$(git rev-parse --show-toplevel)/.claude/skills/hassio-addon-workflow/scripts/preflight.sh"`.
(In the claude_desktop add-on environment the checkout lives at `/data/claude/hassio-addons`;
a copy of this skill may also exist under `~/.claude/skills/` — the checked-in copy is
canonical.)
**Three facts to know before you touch anything**, because each is silent when violated:
- **You cannot test the Docker build** — dockerd does not start here. CI is the only gate.
- **Never `git stash` under `/data/claude`** — `refs/stash` is shared across worktrees and
sessions, so it is not isolated even in your own worktree.
- **Work in a worktree under `/data`, not `/tmp`** (`/tmp` is `noexec`).
Repo-specific traps live in `references/traps.md`. Read it before implementing — it is the
accumulated cost of previous sessions. The repo's own `CLAUDE.md` covers structure, Dockerfile
conventions, CI and lint rules.
---
## 0. Triage — pick the path
Classify the task before doing anything else.
**Light path** — typo/doc fixes, CHANGELOG edits, version bumps, one-file small edits at
mechanism levels 13 (the ladder in step 3), simple coding questions. Flow: one-sentence scope →
pick the simplest mechanism → implement → `scripts/validate.sh <addon> --vs-master` → PR
(version bump + CHANGELOG still required) → resolve bot comments. Skip measurement, both Codex
reviews, and post-deploy measurement — but still label claims Verified / Checked / Assumed
honestly in the report.
**Full loop** — performance/RAM/CPU work, diagnosis ("why is X slow/broken"), anything changing
a shipped default, changes spanning several scripts or mechanisms (a version bump's
config.yaml + CHANGELOG + Dockerfile touch is still light), anything at ladder levels 46, or
when the user asks for a Codex check.
**Escalation rule** — if a light task grows mid-flight (touches a default, needs a new script or
service, reveals a deeper problem), stop and upgrade to the full loop rather than continuing
light.
`references/traps.md` is required reading on **both** paths — traps bite one-liners too.
## 1. Scope before you work
Write this down before generating anything. Two sentences is enough, but they must be explicit:
- **Goal** — the observable outcome, in the user's terms.
- **Non-goals** — what you are deliberately not touching. This is the load-bearing half.
- **Constraints** — what cannot change (defaults for other users, upgrade paths, arch support).
- **Definition of done** — what evidence will demonstrate success.
A diagnosis task and a fix task have different scopes. "Why is it slow?" is answered by
measurement and a written finding; it does not automatically authorize a rewrite. When the user's
ask genuinely spans several changes, list them and say which you are doing now.
Ask about defaults when the answer changes who is affected. Changing a shipped default touches
every user of a 120-add-on repo; making it opt-in touches nobody until they choose. That is the
user's call, not yours, and it is cheap to ask before implementing rather than after.
## 2. Evidence before reasoning
State no cause you have not observed. On a live add-on (`$BUILD_VERSION` set, `HOME=/data/data`)
measure the running system rather than reasoning from source — reviewers will hold you to the
numbers, and source-derived guesses are where wrong plans come from.
Pick the tool to the question:
- **"what is consuming RAM/CPU?"** → `scripts/measure.sh` (PSS and private, not summed RSS; keep
the sample at ≥20 s — a 3 s sample measured 2.3% where 20 s measured 21.6%).
- **"I set an option and nothing happened"** → `scripts/env_trace.sh <VAR> <process>`, which
walks all four stages of the option plumbing and names the one that dropped the value.
- **"is this flag/driver/package actually present?"** → look at the artifact itself:
`/proc/<pid>/cmdline`, `command -v`, `/var/log/apt/history.log`.
`references/traps.md#measurement` explains why summed RSS and reserved-vs-resident both matter.
Before asserting anything, ask what would show it false, then go look:
- "This process is duplicated" → is it? `ps -ef --forest`, compare parents and start times.
- "This costs 500 MB" → is it resident? `grep Rss /proc/<pid>/smaps`.
- "This block never runs" → is its payload in the image? `command -v`, `apt` history.
- "The flag isn't set" → `tr '\0' '\n' < /proc/<pid>/cmdline`.
**Verify the revision you are reading.** `scripts/preflight.sh` compares the checkout's
`config.yaml` version against the running `$BUILD_VERSION`. A stale branch reads as completely
normal and has already cost one full analysis pass.
When you correct yourself mid-analysis, keep the correction visible in your notes and in what you
report. A retracted claim that stays retracted is worth more than one quietly dropped.
## 3. Plan, then have Codex attack it
### Choose the mechanism level first (both paths)
Rank mechanisms and start from the top — choose the level **before writing code** and state it
in the plan. Each step down costs more to write, more to review, and more to keep working across
base-image upgrades:
1. **A config value** — an option, a schema constraint, an existing env var.
2. **An existing knob** the base image already reads (`MAX_RES`, `DRINODE`, `SELKIES_*`).
3. **A few lines in an existing script**, at the point that already runs.
4. **A new init script.**
5. **A new service, wrapper, or long-running process.**
6. **Custom protocol code, or patching someone else's internals.**
Levels 46 automatically mean full loop, and need a reason that survives being said out loud.
"Upstream has no knob for this, and I checked" is a reason. "It felt cleaner" is not. If two
levels both solve it, the higher (simpler) level wins even when the lower one would be more
efficient — see the standing rule at the top.
### Codex review of the plan (full loop only — skip on the light path)
Write the plan around the measurements — each proposed change tied to a number — then get an
independent read **before** writing code. Codex is a genuinely different model reading the files
itself; on this workload it has repeatedly been worth the minutes.
**Use the CLI, not the MCP tool, for prompts of this size.** `mcp__codex__codex` timed out
twice on ~4 KB prompts (2026-08-03); the CLI with the same content succeeded. This overrides the
global CLAUDE.md note recommending the MCP tool — that guidance still holds for short questions.
Run it backgrounded (`--sandbox read-only` means Codex cannot run anything, so paste every number
into the prompt; `- <` feeds the prompt file on stdin):
```bash
codex exec --model gpt-5.6-sol --sandbox read-only --skip-git-repo-check \
-c approval_policy='"never"' - < prompt.md > codex_out.txt 2>&1
```
Write the prompt to a file. Include the files to read, your measurements **with numbers**, the
proposed changes, and explicit instructions to challenge you. Ask direct questions ("is this
really add-on-fixable?", "give the precise flag set") rather than "review this". Codex's sandbox
often cannot run local commands and falls back to reading GitHub, so paste the evidence in rather
than assuming it will find it.
**Codex agrees with confident premises.** It has confirmed a wrong conclusion stated too
confidently, and separately caught a genuine methodology error in the same review. Treat its
confirmations with the same scepticism as its objections — especially about the build.
### Attack your own plan too
Before implementing, spend a moment actively trying to break it:
- What does this do on a host **unlike this one** — no GPU, small `/dev/shm`, aarch64, a VM?
- What happens on **upgrade** to someone who configured this by hand?
- What is the **blast radius** if the assumption underneath it is wrong?
- What am I **inferring** that I could instead **detect at runtime** or **record explicitly**?
That last question is the highest-yield one here; see [the recurring failure
mode](#the-failure-mode-this-loop-keeps-producing).
## 4. Implement
Read `references/traps.md` first; the bashio, s6-env, arch-guard and versioning traps are all
live and each has shipped a bug.
Validate with `scripts/validate.sh <addon> --vs-master`.
Write behavioural tests for anything with branches. Extract an embedded Python heredoc and drive
it against fixtures with stubbed env vars; stub `bashio::*` and `df` to exercise shell paths. Test
**the regression a reviewer described**, not just the happy path — a test that only covers the
case you were already thinking about adds little.
## 5. Simplify — is this the simplest thing that works?
Do this once you have something working and before you ask anyone to review it. The question is
not "is this good code" but **"what is the smallest change that makes the symptom go away, and
why isn't that enough?"** If you cannot answer the second half, the smaller change is the answer.
And restating the standing rule: obviously correct and slightly less efficient beats faster and
harder to review — efficiency only buys complexity when a measurement shows it matters.
Checks worth running against your own diff:
- **Did the diff stay at the ladder level chosen in step 3?** If it crept up a level, either
justify that out loud or redo it at the level you chose.
- **Can this be solved by deleting instead of adding?** A flag that shouldn't be passed, a
process that shouldn't start, a registration that shouldn't be duplicated. Removals cannot
regress on hosts you can't test.
- **Is the fix bigger than the thing it fixes?** That is a smell, not a rule — but it usually
means the problem was framed one level too deep.
- **How does this fail in three years**, when the base image, Electron, or upstream has moved?
Code that reads a documented knob keeps working. Code that reaches into private internals
does not.
The evidence from this repo is blunt:
- A rejected PR spent a **388-line TCP proxy plus a 142-line monkeypatch of a private upstream
method** to reclaim 159 MB — placing custom transport code in the path of every API request.
Both independent reviewers said close it rather than iterate on it.
- A ~180-line `ctypes` probe was written to decide whether to enable GPU flags. It worked
perfectly, proved the driver was fine, and the change **still did nothing**, because the
question it answered was not the question that mattered.
- A resolution cap shipped as a **new init script writing an s6 envdir** — the wrong mechanism
entirely. Renaming the option to the env var the service already reads (level 1) would have
worked, and the new script did not.
In all three cases the simpler option existed and was skipped. Being able to build the
complicated thing is not a reason to.
## 6. Codex attacks the code (full loop only)
Same invocation, pointed at `git diff origin/master...HEAD` plus the reasoning behind each hunk.
Ask specifically what breaks: upgrade paths, hosts unlike this one, users who configured things
by hand. Ask it directly whether a simpler mechanism would achieve the same thing — an outside
reader spots one-level-too-deep framing far more easily than the person who just built it.
## 7. Open the PR
What CI actually gates on a PR: **`CHANGELOG.md` updated** (hard `exit 1`), the **HA add-on
linter** (`frenck/action-addon-linter` in `onpr_check-pr.yaml`, no `continue-on-error` — a
config.yaml schema error blocks the PR), and the **add-on image build**. The non-blocking lint
is the *weekly Super-Linter*, not the PR checks — don't confuse the two. Nothing checks the
version bump — but bump it
anyway (`X.Y.Z.N`, never `X.Y.Z-N`, see `references/traps.md#versioning`), because Supervisor will
not offer a rebuild without it, and update `README.md` if you added options. Match the existing
CHANGELOG heading format, `## X.Y (DD-MM-YYYY)`.
Write the body to a file and use `gh pr create --body-file`. State what was measured, what
changed, **what is not verified**, and how to roll back the riskiest hunk on its own.
## 8. Resolve review comments
`scripts/pr_review.sh list|reply|resolve|status|watch <PR>`.
For every comment, **reproduce the claim before agreeing or disagreeing.** A CodeRabbit finding
that `grep -E '^…$'` anchors per line — letting a multi-line value pass validation — was real and
provable in one command. A finding that a changelog heading needed a blank line was a false
positive against this repo's `.markdownlint.yaml`.
Reply with the evidence, then resolve the thread. **Push back when you are right**, on the thread,
so the maintainer can overrule you — a resolved-but-wrong thread is worse than an open one.
Equally, when a reviewer is right, fix the cause rather than papering over the symptom.
## 9. Verify before declaring done
Do not write "this should work". Either it was exercised, or say plainly that it wasn't.
On the light path, verification is `validate.sh` plus CI — anything beyond that is **Assumed**,
and the report must say so plainly.
Distinguish three states and never let them blur:
- **Verified** — you ran it and observed the result.
- **Checked but not exercised** — it parses, lints, type-checks.
- **Assumed** — reasoning only. Name the assumption.
**CI passing and the PR merging prove the build works, not that the change does anything.** Once
the rebuilt add-on is running, re-run the measurement that motivated the work. Both changes in the
session that produced this skill passed CI, merged, and were **inert**:
- The Xvfb resolution cap wrote its env file correctly and Xvfb still started at the base-image
default — wrong env mechanism for that service.
- The GPU flags reached Chromium's command line exactly as intended, and the GPU process still
reported `--use-gl=disabled`, having overridden them after its own init failed.
Cheap post-deploy checks: `tr '\0' '\n' < /proc/<pid>/cmdline` for flags, `/proc/<pid>/environ`
for env vars, `scripts/env_trace.sh <VAR> <process>` for the whole option plumbing, and a repeat
CPU/PSS sample against the pre-change numbers.
Some fixes cannot be self-verified. A service reads its environment only at start, so an env-var
fix is unproven until the add-on restarts — which needs the user, or `ha-cli` with their
agreement. If you cannot restart, the change is **Assumed**, not Verified, and must be reported
that way.
## 10. Calibrate and report
Close against the scope from step 1, not against what you ended up doing. Structure:
```
What was asked / what shipped — mapped to the original scope
Evidence — the numbers, before and after
Verified — observed, with how
Not verified — and why (e.g. no dockerd locally; CI is the gate)
Known broken / left out — explicitly, including anything descoped
Risk + rollback — the riskiest hunk and how to revert it alone
```
Lead with anything that did not work. A merged PR that achieved nothing is the single most
important sentence in the report, and it must not appear after the summary of what went well.
Give confidence per claim, not one blanket number, and make it mean something: "measured", "CI
verified", "unverified — reasoning only". If a number came from one host, say so.
---
## The failure mode this loop keeps producing
Every bug shipped from the source session came from one move: **measuring this host correctly,
then generalising it to all hosts.**
- `/dev/shm` was 7.7 GB here, so a flag looked useless — but Home Assistant ignores `shm_size`, so
elsewhere it is Docker's 64 MB default and removing the flag reintroduces a crash loop.
- An MCP entry was identified by its URL — but that URL is the documented default, so the rule
would have deleted a user's hand-written configuration.
- A GPU probe created a hardware context — but that proved the driver worked, not that Chromium's
GPU path did.
The pattern is always *inference standing in for detection*. Before changing a default, ask what
this is like on a host unlike yours. Prefer detecting the condition at runtime over asserting it.
When ownership matters, **record it rather than infer it**.
## Token efficiency
`rtk` wraps commands via hook automatically. Compress large structured output you will re-read
with `mcp__headroom__headroom_compress` (skip error/stack output). Use
`mcp__tokensave__tokensave_context` for code exploration. Redirect big output to a file and read
only what you need, and poll CI in a **background** task rather than blocking.
## Bundled files
| File | Use |
|---|---|
| `scripts/preflight.sh` | Tools, live-add-on check, revision-vs-running-image check. Exits 2 on mismatch |
| `scripts/measure.sh` | RAM (PSS/private) + CPU snapshot; reserved vs resident. Sample ≥20 s |
| `scripts/env_trace.sh` | Trace one env var through all four plumbing stages — for "my option did nothing" |
| `scripts/validate.sh` | Local linters + CI gates; `--vs-master` shows only findings your diff added |
| `scripts/pr_review.sh` | Fetch / reply to / resolve PR review threads; watch checks |
| `references/traps.md` | Repo-specific traps — read before implementing |
Each script's header explains its reasoning; read the script when you use it.

View File

@@ -0,0 +1,212 @@
# Repo-specific traps
Things that look correct and are not. Each cost real time or shipped broken. Read this before
implementing; skim the headings, read the ones you're about to touch.
The repo's own `CLAUDE.md` documents structure, Dockerfile conventions, `updater.json`, CI
workflows and lint rules — that is not repeated here.
## Contents
- [Environment and workspace](#environment-and-workspace)
- [Measurement](#measurement)
- [Passing values into base-image services](#passing-values-into-base-image-services)
- [Shell and bashio](#shell-and-bashio)
- [Dockerfile and architecture](#dockerfile-and-architecture)
- [Versioning](#versioning)
- [Chromium / Electron under Xvfb](#chromium--electron-under-xvfb)
- [CI and review bots](#ci-and-review-bots)
---
## Environment and workspace
**The checkout is probably on the wrong branch.** Checkouts under `/data/claude` are shared and
persistent; another session leaves them wherever it finished. A stale branch looks entirely
normal. Compare the add-on's `config.yaml` `version` against the running `$BUILD_VERSION` before
trusting anything you read. `scripts/preflight.sh` does this.
**Never run `git stash` under `/data/claude`.** `refs/stash` is shared across every worktree and
concurrent session, so it is *not* isolated even in your own worktree. A bare `stash` / `stash
pop` pair in a clean worktree once restored another session's stash, producing conflict markers
in six untouched files. To compare a file against another revision use
`git show <rev>:<path> > /tmp/x`. If a pop does go wrong: a conflicted pop **keeps** the stash
entry, so nothing is lost — confirm `git rev-parse HEAD` matches what you pushed, then
`git reset --hard HEAD`.
**Work in a worktree under `/data`, not `/tmp`**`/tmp` is `noexec`, so scripts there won't run.
```bash
git worktree add --detach /data/claude/.work/<task> origin/master
```
**You cannot test the Docker build.** dockerd does not start in this environment. CI is the only
gate. One observed run took ~3 hours, with 20+ runs queued against 2 executing — that was account
runner contention, not the diff. Check `gh run list` before concluding your PR is stuck. Poll in
a background task, and never claim the build is verified when it hasn't run.
## Measurement
**Summed RSS overstates savings.** Shared library pages are counted once per process, so removing
a duplicate frees its *private* memory, not its RSS. Measured example: four MCP shims summed to
882 MB RSS but 643 MB PSS / 564 MB private, and per-process private ranged 54 MB down to 2 MB —
which completely changes which duplicate is worth removing. Quote private when arguing "removing
this saves N MB".
**A large mapping is often not resident.** SysV/tmpfs segments are lazily populated. Xvfb's
506 MB framebuffer shows `Rss: 0` in `/proc/<pid>/smaps`. Check before calling anything a leak.
**`/proc/meminfo` and `free` show host figures** — there is no memory cgroup namespace here.
Never attribute those totals to the add-on.
**`rtk` filters some command output.** For a complete listing, redirect to a file and read that
(`ps ... > $SP/ps.txt`), or use `rtk proxy <cmd>`.
## Passing values into base-image services
The plumbing has four stages. `scripts/env_trace.sh <VAR> <process>` walks all four and tells
you which one drops the value — use it rather than reasoning about this from memory.
1. `/data/options.json` — the user's saved options.
2. **Injected export block**`.templates/00-global_var.sh` writes a literal
`export <option>='<value>'` block into *every* service `run` script, using the option name
**verbatim**. So `max_resolution` *is* injected; it just isn't a name any service reads.
`MAX_RES` would be both injected and read.
3. `container_environment` — s6's envdir, read **only** by services whose shebang is
`#!/usr/bin/with-contenv`.
4. The running process — the only stage that decides behaviour.
**Name the option exactly as the env var the service reads** (uppercase), the way `DRINODE`,
`KEYBOARD` and `TZ` already do. Verified live: `DRINODE` appears as `export DRINODE=…` in all 16
service run scripts including `svc-xorg`, and Xvfb runs with `-vfbdevice /dev/dri/renderD128`.
**Two consequences that are easy to get wrong:**
- `00-global_var.sh` is cont-init **00**. Any cont-init script numbered higher runs *after* the
injection, so it cannot change what a service will see through stage 2.
- LSIO's `svc-xorg` starts `#!/usr/bin/env bashio`, **not** `with-contenv`, so it never reads
stage 3 at all. Writing `container_environment` for it is a silent no-op — that shipped: the
file was written 6 seconds before Xvfb started, and Xvfb still came up at the base-image
default.
**Renaming an option to match a base-image env var moves validation out of your script and into
the schema.** `00-global_var.sh` exports empty strings (only objects/arrays/nulls are dropped),
and base-image scripts typically test `${VAR+x}`*set*-ness, not emptiness. So an empty
`MAX_RES` becomes `Xvfb -screen 0 "x24"` and the X server does not start. If you make this move,
constrain the value in `config.yaml` (`match(^[0-9]{1,5}x[0-9]{1,5}$)?`) in the same commit, or
keep a guard script.
**Open question, unresolved:** what Supervisor does with a stored `options.json` key that no
longer exists in the new schema — error, warn, or silently drop. This decides whether renaming an
option is safe on upgrade. The `monica` add-on shipped exactly such a rename
(`MEILISEARCH_KEY``meilisearch_key`) with no migration, which is weak evidence it is
tolerated. The base image has an `init-migrations` oneshot reading `/migrations` if a migration
is needed. Confirm before renaming a shipped option.
Whichever mechanism you use, verify the service actually received it:
```bash
tr '\0' '\n' < /proc/<pid>/environ | grep <VAR>
```
**`cont-init.d` runs as root before s6 services start** — that part is true and is the right
place for filesystem and permission setup.
**Anything needing an X display must not run in `cont-init.d`** — Xvfb isn't up yet. Put it in
the openbox autostart. (ANGLE's OpenGL backend, for instance, fails with "Could not open the
default X display".)
## Shell and bashio
**`bashio::config` for lists**: `while read ... < <(bashio::config ...)` silently yields an empty
list under errexit — bashio's internals return non-zero and process substitution inherits the
failure. Capture with `$(...)` first, then feed a here-string.
**Scripts shared by symlink**: `80-configuration.sh` and friends are shared with the webtop
add-ons. Put add-on-specific logic in a new numbered script instead of editing them.
**`grep -E '^…$'` anchors per line.** A multi-line config value passes validation on its first
line and is then used verbatim. Use bash's `[[ =~ ]]`, which anchors the whole string.
## Dockerfile and architecture
**Prefer `BUILD_ARCH` over `TARGETARCH`** — the repo's builder passes `BUILD_ARCH` explicitly,
while `TARGETARCH` is BuildKit-provided and may or may not be populated.
Either way the variable must be declared with `ARG <NAME>` **in the build stage that uses it**;
without that it expands empty, the guard never matches, and the block silently does nothing —
which is the same dead-`if` failure described just below, and the usual cause of it.
**Verify a guarded block actually ran** rather than assuming. Check whether its payload exists in
the running image (`command -v <tool>`), and cross-check `/var/log/apt/history.log` for the
matching `apt-get install` line. An `if` block whose condition never matched leaves no trace and
no error — one such block sat dead for weeks while appearing to guarantee driver verification.
**Don't test for distro-specific filenames.** A guard on
`/usr/share/vulkan/icd.d/intel_icd.x86_64.json` named a file Debian does not ship (it installs
`intel_icd.json`), so fixing the arch variable alone would have turned dead code into a failing
build.
## Versioning
**`X.Y.Z.N`, never `X.Y.Z-N`.** A hyphen parses as a semver pre-release, which Supervisor treats
as *older* than `X.Y.Z` — the update is never offered.
Date-based versions (`2026.08.03`) are common here. Check whether master has already moved to the
version you were about to use.
## Chromium / Electron under Xvfb
**Xvfb offers only indirect/software GLX**, so Chromium probes it, fails, and falls back to CPU
rendering — the GPU process runs `--use-gl=disabled` and the renderer `--disable-gpu-compositing`.
**Passing ANGLE flags is not sufficient.** `--ozone-platform=x11 --use-gl=angle
--use-angle=gl-egl` reached Chromium's command line exactly as intended and the GPU process
*still* reported `--use-gl=disabled`, having overridden the flag after its own init failed.
**A standalone ANGLE probe proves less than it appears to.** Loading Claude Desktop's bundled
`libEGL.so`, initializing the OpenGL backend and reading back
`ANGLE (Intel, Mesa Intel(R) Graphics (ADL-N), OpenGL 4.6)` proves the driver and device work —
not that Chromium's GPU process, sandbox, dmabuf import and X11 presentation path work. Codex
flagged this distinction during review and was right.
`--use-angle=gles-egl` is rejected outright by Mesa ("Intel or NVIDIA OpenGL ES drivers are not
supported").
**`--disable-dev-shm-usage`** is a workaround for Docker's 64 MB default `/dev/shm`. Home
Assistant **ignores** the add-on's `shm_size`, so the real size varies per install — it was 7.7 GB
on one host. Detect at runtime rather than assuming either way; keep the flag when the size
cannot be determined, because the crash it prevents is worse than its overhead.
## CI and review bots
**What CI actually gates** — checked against the workflows, because assuming costs a cycle:
- **CHANGELOG updated** — the only hard gate (`onpr_check-pr.yaml`, its single `exit 1`).
- **Add-on image build** — real, and slow; one run took ~3 h.
- **Lint** — `lint.yml` runs super-linter with `continue-on-error: true` at both call sites, so
it *cannot* fail a PR. Fix real findings anyway, but do not treat lint as a blocker.
- **Version bump** — no workflow checks it. It is repo convention, and required for Supervisor to
offer the rebuild, but it will not fail CI.
**CI rewrites your shell scripts.** `lint.yml` runs `shfmt -w -i 4 -ci -bn -sr` over every `*.sh`
and `run`, plus a `chmod +x` pass, on schedule. Repo-wide reformatting commits land on master
without your involvement — another reason a shared checkout goes stale mid-task.
**Reviewers**: CodeRabbit (deepest — often runs scripts to prove a claim; reviews ~9 minutes
after the PR opens, or on `@coderabbitai review`), chatgpt-codex-connector, Copilot, Codacy.
**Codacy `action_required` is this repo's normal state.** Other open PRs show the same. It
exposes no annotations via the API, so its findings are only visible in the maintainer's Codacy
account. Note it and move on rather than guessing.
**Resolving a review thread requires GraphQL** (`resolveReviewThread`); the REST API cannot do it.
`scripts/pr_review.sh` wraps fetch / reply / resolve.
**The repo's `.markdownlint.yaml` does not disable MD022/MD032**, so a CHANGELOG will show
dozens of pre-existing heading/list findings. They are noise because lint is `continue-on-error`,
not because the config exempts them — don't cite the config as a reason to ignore a finding.
**Separate new lint findings from pre-existing ones** by linting the same file at `origin/master`
and diffing the result sets — otherwise you chase warnings that were already there.
`scripts/validate.sh --vs-master` does this.

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Trace one env var through the whole add-on plumbing, to answer "I set the option and nothing
# happened".
#
# This is the single highest-value diagnostic for this repo, because the plumbing has four
# separate stages and a value can be present at stage 3 and absent at stage 4 while every script
# involved reports success. That exact case shipped: MAX_RES was correctly written to
# /run/s6/container_environment/MAX_RES six seconds before Xvfb started, and Xvfb still came up
# at the base-image default.
#
# The four stages:
# 1. /data/options.json the user's saved add-on options
# 2. injected export block .templates/00-global_var.sh writes `export <option>='<v>'`
# into every service run script — as cont-init 00, i.e. BEFORE
# any higher-numbered cont-init script can influence it
# 3. container_environment s6's envdir, read only by services using `with-contenv`
# 4. the running process the only stage that actually matters
#
# Two consequences worth internalising:
# * A cont-init.d script numbered >00 cannot change what stage 2 injected.
# * A service starting `#!/usr/bin/env bashio` (LSIO's svc-xorg does) never reads stage 3, so
# writing container_environment for it is a silent no-op.
#
# Usage: env_trace.sh <VAR> [process-name-or-pid]
# env_trace.sh MAX_RES Xvfb
# env_trace.sh DRINODE Xvfb # a working example, for comparison
set -uo pipefail
VAR="${1:?usage: env_trace.sh <VAR> [process-name-or-pid]}"
TARGET="${2:-}"
# VAR is interpolated into grep/sed patterns below — restrict it to a valid env var name
case "$VAR" in
[A-Za-z_]*) [ -z "${VAR//[A-Za-z0-9_]/}" ] || { echo "invalid env var name: $VAR" >&2; exit 1; } ;;
*) echo "invalid env var name: $VAR" >&2; exit 1 ;;
esac
echo "== tracing ${VAR} =="
echo
echo "1. /data/options.json (the user's saved options)"
if [ -f /data/options.json ]; then
python3 - "$VAR" <<'PY'
import json, sys
var = sys.argv[1]
try:
opts = json.load(open('/data/options.json'))
except Exception as err:
print(f" could not parse: {err}"); raise SystemExit
hit = {k: v for k, v in opts.items() if k.lower() == var.lower()}
if hit:
for k, v in hit.items():
shown = '<empty string>' if v == '' else repr(v)
print(f" {k} = {shown}")
if k != var:
print(f" NOTE: option is named '{k}', not '{var}' — the injected export uses the")
print(f" option name verbatim, so a service reading ${var} will not see it.")
else:
print(f" absent (so the add-on default from config.yaml applies, if any)")
PY
else
echo " /data/options.json not present (not running as an add-on?)"
fi
echo
echo "2. injected 'ADDON ENV' export block in service run scripts"
found=0
for d in /etc/s6-overlay/s6-rc.d /etc/services.d; do
[ -d "$d" ] || continue
while IFS= read -r rs; do
if grep -qE "^export ${VAR}=" "$rs" 2> /dev/null; then
echo " $(grep -E "^export ${VAR}=" "$rs" | head -1) <- $rs"
found=1
fi
done < <(find "$d" -name run -type f 2> /dev/null)
done
[ "$found" -eq 0 ] && echo " ${VAR} not injected into any service run script"
echo
echo "3. s6 container_environment (only read by services using #!/usr/bin/with-contenv)"
seen3=0
for d in /var/run/s6/container_environment /run/s6/container_environment; do
if [ -f "$d/$VAR" ]; then
echo " $d/$VAR = [$(cat "$d/$VAR")]"; seen3=1
fi
done
[ "$seen3" -eq 0 ] && echo " not present in either envdir"
echo
echo "4. the running process (the only stage that decides behaviour)"
if [ -z "$TARGET" ]; then
echo " no target given; pass a process name or pid as \$2"
else
# Prefer an exact process-name match. A -f substring match picks up this script's own shell
# (its command line contains the name you searched for), which produces confusing noise.
if [ -d "/proc/$TARGET" ]; then
pids="$TARGET"
else
pids=$(pgrep -x "$TARGET" 2> /dev/null | head -3)
[ -z "$pids" ] && pids=$(pgrep -f "$TARGET" 2> /dev/null | grep -vE "^($$|$PPID)$" | head -3)
fi
if [ -z "$pids" ]; then
echo " no process matching '$TARGET'"
else
for pid in $pids; do
comm=$(tr -d '\0' < "/proc/$pid/comm" 2> /dev/null)
val=$(tr '\0' '\n' < "/proc/$pid/environ" 2> /dev/null | sed -n "s/^${VAR}=//p")
if [ -n "$val" ]; then
echo " pid=$pid ($comm): ${VAR}=[$val]"
else
echo " pid=$pid ($comm): ${VAR} NOT SET"
# Naming the shebang is usually the whole answer.
for d in /etc/s6-overlay/s6-rc.d /etc/services.d; do
while IFS= read -r rs; do
if grep -qiE "exec .*${comm}|${comm}" "$rs" 2> /dev/null; then
echo " its service $rs starts: $(head -1 "$rs")"
head -1 "$rs" | grep -q with-contenv \
&& echo " -> uses with-contenv, so stage 3 WOULD reach it" \
|| echo " -> NOT with-contenv, so stage 3 can never reach it"
break 2
fi
done < <(find "$d" -name run -type f 2> /dev/null)
done
fi
# What it was actually launched with beats any theory about its environment.
tr '\0' '\n' < "/proc/$pid/cmdline" 2> /dev/null | tail -n +2 |
grep -iE "res|screen|${VAR}" | head -3 | sed 's/^/ argv: /'
done
fi
fi
echo
echo "== reading the result =="
echo " present at 4 -> the value reached the process; the bug is elsewhere"
echo " at 1+2 but not 4 -> service started before injection, or reads a different name"
echo " at 1+3 but not 2 or 4 -> classic silent no-op: wrong mechanism for this service"
echo " at 1 only -> option name does not match any env var a service reads"

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# RAM/CPU snapshot of the running add-on, built to avoid the two mistakes that make such
# snapshots wrong:
#
# 1. Summed RSS double-counts shared pages. Removing a duplicate process frees its *private*
# memory, not its RSS. So PSS and private are reported alongside, and private is the number
# to quote when arguing "removing this saves N MB".
# 2. A big mapping is not necessarily resident. Large SysV/tmpfs segments are lazily populated,
# so reserved size is reported separately from resident.
#
# Note /proc/meminfo and free show HOST figures (no memory cgroup namespace) — never attribute
# those to the add-on.
#
# Usage: measure.sh [cpu-sample-seconds] (default 20)
set -uo pipefail
SAMPLE="${1:-20}"
if [ "$SAMPLE" -lt 20 ]; then
echo "WARNING: a ${SAMPLE}s sample understates CPU badly (a 3s sample measured 2.3% where" >&2
echo " 20s measured 21.6% for the same process). Use >=20s for anything you report." >&2
fi
OUT="${SCRATCH:-${TMPDIR:-/tmp}}/addon-measure.$$"
mkdir -p "$OUT"
# rtk filters some output; redirect to a file to get the complete list.
ps -eo pid,ppid,user,rss,pcpu,etimes,args --sort=-rss > "$OUT/ps.txt" 2>&1
echo "== totals =="
awk 'NR>1{s+=$4; n++} END{printf " processes=%d summed RSS=%.0f MB (overstates: shared pages counted per-process)\n", n, s/1024}' "$OUT/ps.txt"
awk '{t+=$2} END{printf " threads=%d\n", t}' <(ps -eo pid,nlwp --no-headers 2> /dev/null)
echo
echo "== per-process memory (top 20 by PSS) =="
printf ' %-28s %8s %8s %8s\n' COMMAND RSS PSS PRIVATE
python3 - "$OUT" <<'PY'
import os, sys
rows = []
for pid in filter(str.isdigit, os.listdir('/proc')):
try:
cmd = open(f'/proc/{pid}/cmdline', 'rb').read().replace(b'\x00', b' ').decode(errors='replace').strip()
if not cmd:
continue
rss = pss = priv = 0
for line in open(f'/proc/{pid}/smaps_rollup'):
k, _, v = line.partition(':')
v = v.split()[0] if v.split() else '0'
if k == 'Rss': rss = int(v)
elif k == 'Pss': pss = int(v)
elif k in ('Private_Dirty', 'Private_Clean'): priv += int(v)
except Exception:
continue
rows.append((pss, rss, priv, pid, cmd))
rows.sort(reverse=True)
tr = tp = tv = 0
for pss, rss, priv, pid, cmd in rows:
tr += rss; tp += pss; tv += priv
for pss, rss, priv, pid, cmd in rows[:20]:
name = (cmd[:26] + '..') if len(cmd) > 28 else cmd
print(f" {name:<28} {rss/1024:7.0f}M {pss/1024:7.0f}M {priv/1024:7.0f}M")
print(f"\n {'TOTAL':<28} {tr/1024:7.0f}M {tp/1024:7.0f}M {tv/1024:7.0f}M")
print(" ^ quote PRIVATE when claiming what removing a process would free.")
PY
echo
echo "== reserved-but-not-resident (lazy allocations, NOT leaks) =="
ipcs -m 2>/dev/null | awk 'NR>3 && $5 ~ /^[0-9]+$/ && $5 > 50000000 {printf " SysV shm %.0f MB (owner %s) — check Rss in /proc/<pid>/smaps before calling it used\n", $5/1048576, $3}'
echo
echo "== CPU over ${SAMPLE}s (idle unless you are driving the UI) =="
# utime+stime. Parsed after the LAST ')' because field 2 is (comm) and may contain spaces —
# a plain $14+$15 is wrong for anything like 'npm exec @foo' and silently reports a fabricated
# number rather than failing.
jiffies() { awk -F') ' '{n=split($NF,a," "); print a[12]+a[13]}' "/proc/$1/stat" 2>/dev/null; }
# jiffies are USER_HZ units — almost always 100, but read it rather than assume it
HZ=$(getconf CLK_TCK 2>/dev/null) && [ "$HZ" -gt 0 ] 2>/dev/null || HZ=100
# Sample EVERY readable process, not the top-N of ps.txt: that list is sorted by RSS,
# and the busiest process is not necessarily a big one.
declare -A t0
for d in /proc/[0-9]*; do
pid=${d#/proc/}
[ -r "$d/stat" ] && t0[$pid]=$(jiffies "$pid")
done
sleep "$SAMPLE"
for pid in "${!t0[@]}"; do
[ -r "/proc/$pid/stat" ] || continue
t1=$(jiffies "$pid") || continue
[ -n "$t1" ] && [ -n "${t0[$pid]}" ] || continue
delta=$(( t1 - ${t0[$pid]} ))
[ "$delta" -gt 0 ] || continue
pct=$(awk -v d="$delta" -v s="$SAMPLE" -v hz="$HZ" 'BEGIN{printf "%.2f", d*100/(hz*s)}')
comm=$(tr -d '\0' < "/proc/$pid/comm" 2>/dev/null)
echo "$pct $pid $comm"
done | sort -rn | head -12 | awk '{printf " %6s%% %-8s %s\n", $1, $2, $3}'
echo
echo " established conns on :8082/:3000/:3001 = $(ss -tn 2>/dev/null | grep -cE 'ESTAB.*:(8082|3000|3001)')"
echo " (those are claude_desktop/webtop viewer ports; 0 here means CPU above is idle burn)"
echo " raw ps: $OUT/ps.txt"

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Work through bot review comments on a PR. Resolving a thread needs the GraphQL API (the REST
# API cannot do it), which is the only reason this script exists.
#
# pr_review.sh list <PR> every inline comment, grouped
# pr_review.sh status <PR> checks + unresolved thread count
# pr_review.sh reply <PR> <COMMENT_ID> <text|@file>
# pr_review.sh resolve <PR> <THREAD_ID...|--all> --all = every unresolved, asks first
# pr_review.sh watch <PR> [minutes] poll checks (run this backgrounded)
#
# Reviewers seen here: coderabbitai (deepest; reviews ~9 min after open, or on
# "@coderabbitai review"), chatgpt-codex-connector, Copilot, Codacy.
#
# Verify every claim before agreeing. Bots are frequently right and occasionally confidently
# wrong; a reproduction takes a minute and decides it either way. Push back with evidence when
# you are right — a resolved-but-wrong thread is worse than an open one.
set -uo pipefail
REPO="${HASSIO_REPO:-}"
[ -z "$REPO" ] && REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2> /dev/null)
[ -z "$REPO" ] && { echo "cannot determine repo; set HASSIO_REPO=owner/name" >&2; exit 1; }
echo "repo: $REPO" >&2
CMD="${1:-}"; PR="${2:-}"
[ -z "$CMD" ] || [ -z "$PR" ] && { sed -n '2,16p' "$0" | sed 's/^# \?//'; exit 1; }
case "$CMD" in
list)
echo "== inline comments on #$PR =="
gh api "repos/$REPO/pulls/$PR/comments" --paginate \
--jq 'sort_by(.created_at)[] | "=== [\(.id)] \(.user.login) | \(.path):\(.line // .original_line) ===\n\(.body)\n"'
echo "== review bodies =="
gh api "repos/$REPO/pulls/$PR/reviews" \
--jq '.[] | select(.body != "") | "--- \(.user.login) (\(.state)) ---\n\(.body[0:4000])\n"'
;;
status)
gh pr checks "$PR" 2>&1 | head -15
echo
gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:50){nodes{id isResolved path comments(first:1){nodes{author{login}}}}}}}}" \
--jq '.data.repository.pullRequest.reviewThreads.nodes[] | "\(if .isResolved then "resolved" else "OPEN " end) \(.id) \(.comments.nodes[0].author.login) \(.path)"'
;;
reply)
ID="${3:?comment id}"; BODY="${4:?text or @file}"
if [ "${BODY#@}" != "$BODY" ]; then
out=$(gh api "repos/$REPO/pulls/$PR/comments/$ID/replies" -F body=@"${BODY#@}" --jq '.id' 2>&1)
rc=$?
else
out=$(gh api "repos/$REPO/pulls/$PR/comments/$ID/replies" -f body="$BODY" --jq '.id' 2>&1)
rc=$?
fi
# Silently "succeeding" here is worse than failing: a later session reads the transcript and
# believes a reviewer was answered when they were not.
if [ "$rc" -eq 0 ] && [ -n "$out" ]; then
echo "replied to $ID (comment $out)"
else
echo "FAILED to reply to $ID: $out" >&2
echo " (top-level review bodies have different ids and cannot take replies here)" >&2
exit 1
fi
;;
resolve)
shift 2
ids="$*"
if [ "${ids:-}" = "--all" ]; then
echo "About to resolve EVERY unresolved thread. Only do this if you have read and"
echo "answered each one — a resolved-but-wrong thread is worse than an open one."
gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:100){nodes{isResolved path comments(first:1){nodes{author{login} body}}}}}}}" \
--jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | " - \(.comments.nodes[0].author.login) \(.path): \(.comments.nodes[0].body[0:90])"'
printf 'Type "yes" to resolve all: '; read -r ok
[ "$ok" = "yes" ] || { echo "aborted"; exit 1; }
ids=""
elif [ -z "$ids" ]; then
echo "usage: pr_review.sh resolve <PR> <THREAD_ID...> (or --all, with confirmation)" >&2
echo "resolve each thread as you answer it; get ids from: pr_review.sh status $PR" >&2
exit 1
fi
if [ -z "$ids" ]; then
ids=$(gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:50){nodes{id isResolved}}}}}" \
--jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | .id')
fi
[ -z "$ids" ] && { echo "nothing unresolved"; exit 0; }
rfail=0
for id in $ids; do
r=$(gh api graphql -f query="mutation{resolveReviewThread(input:{threadId:\"$id\"}){thread{isResolved}}}" \
--jq '.data.resolveReviewThread.thread.isResolved' 2>&1)
echo " $id -> $r"
[ "$r" = "true" ] || rfail=1
done
# exiting 0 on a failed mutation would let a session believe threads were resolved
exit "$rfail"
;;
watch)
MINS="${3:-180}" # the addon build alone has taken ~3h; 20 was far too short
for i in $(seq 1 "$MINS"); do
c=$(gh pr checks "$PR" 2> /dev/null | awk '{print $1"="$2}' | tr '\n' ' ')
if [ -z "$c" ]; then
# Normal in the first minutes after `gh pr create`, and also whenever gh errors.
# Calling that "settled" would report success for checks that never ran.
echo "[$i] no checks reported yet (gh returned nothing) — still waiting"
sleep 60; continue
fi
echo "[$i] $c"
case "$c" in
*pending*) sleep 60 ;;
*fail* | *error* | *cancel*) echo "settled — with FAILURES (see above)"; wfail=1; break ;;
*) echo "settled — all passing"; break ;;
esac
done
echo "note: long queues here are usually account runner contention, not your diff."
exit "${wfail:-0}"
;;
*) echo "unknown: $CMD"; exit 1 ;;
esac

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Orient before starting add-on work: what tools exist, are we inside the running add-on, and
# — the one that actually bites — does the checkout match what is running?
#
# Usage: preflight.sh [repo-path] [addon-slug]
set -uo pipefail
REPO="${1:-/data/claude/hassio-addons}"
SLUG="${2:-}"
echo "== tools =="
for c in gh git codex rtk headroom tokensave shellcheck hadolint yamllint python3 jq; do
printf ' %-11s %s\n' "$c" "$(command -v "$c" > /dev/null 2>&1 && echo yes || echo MISSING)"
done
[ -x /data/codex/bin/codex-real ] && echo " codex-real yes (prefer: codex exec --model gpt-5.6-sol)"
echo
echo "== running add-on =="
if [ -n "${BUILD_VERSION:-}" ]; then
echo " BUILD_VERSION=$BUILD_VERSION HOME=${HOME:-?}"
echo " -> live measurement is possible; see measure.sh"
else
echo " not inside a running add-on (no BUILD_VERSION); source-only analysis"
fi
echo
echo "== repo =="
# git-aware check: in a worktree .git is a file, not a directory
if ! git -C "$REPO" rev-parse --git-dir > /dev/null 2>&1; then
echo " no git repo at $REPO"
exit 0
fi
cd "$REPO" || exit 0
branch=$(git branch --show-current 2> /dev/null || echo "(detached)")
echo " path=$REPO"
echo " branch=$branch"
# Another session may be mid-operation in this shared checkout.
echo " recent reflog (entries you did not make mean another session is active):"
git reflog --date=iso -3 2> /dev/null | sed 's/^/ /'
# The trap this exists for: a stale branch looks entirely normal.
if [ -n "${BUILD_VERSION:-}" ]; then
# Hostname is <8-hex>-<slug-with-dashes>. Anchor the hex to 8 chars: an unanchored
# [0-9a-f]* also eats real prefixes (dab-radio -> radio, cafe-monitor -> monitor).
# Slugs may legitimately contain dashes (birdnet-go), so try both forms.
if [ -z "$SLUG" ] && [ -n "${HOSTNAME:-}" ]; then
base=$(printf '%s' "$HOSTNAME" | sed 's/^[0-9a-f]\{8\}-//')
for cand in "$(printf '%s' "$base" | tr '-' '_')" "$base"; do
[ -f "$REPO/$cand/config.yaml" ] && { SLUG="$cand"; break; }
done
[ -z "$SLUG" ] && SLUG="$base"
fi
cfg="$REPO/$SLUG/config.yaml"
if [ ! -f "$cfg" ]; then
echo
echo " could not find $SLUG/config.yaml — pass the slug as \$2 to enable the"
echo " revision check (this is the check the script exists for)." >&2
exit 3
fi
if [ -f "$cfg" ]; then
here=$(grep -E '^version:' "$cfg" | head -1 | tr -d "\"'" | awk '{print $2}')
echo
echo " $SLUG/config.yaml version = $here"
echo " running image BUILD_VERSION = $BUILD_VERSION"
if [ "$here" = "$BUILD_VERSION" ]; then
echo " MATCH — this checkout corresponds to the running image."
else
echo " MISMATCH — this branch is NOT what is running."
git fetch origin master --quiet 2> /dev/null
master=$(git show origin/master:"$SLUG/config.yaml" 2> /dev/null |
grep -E '^version:' | head -1 | tr -d "\"'" | awk '{print $2}')
echo " origin/master version = ${master:-unknown}"
echo " -> work from origin/master; analysing this branch will mislead you."
echo
echo "== suggested isolated worktree (/tmp is noexec; use /data) =="
echo " git worktree add --detach /data/claude/.work/<task> origin/master"
echo " NOTE: never 'git stash' under /data/claude — refs/stash is shared."
exit 2
fi
fi
fi
echo
echo "== suggested isolated worktree (/tmp is noexec; use /data) =="
echo " git worktree add --detach /data/claude/.work/<task> origin/master"
echo " NOTE: never 'git stash' under /data/claude — refs/stash is shared across worktrees."

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Run every linter that CI will run and that works locally. The Docker build is deliberately not
# attempted: dockerd does not start in this environment, so CI is the only gate for it — say that
# rather than implying the build was checked.
#
# --vs-master re-lints each changed file at origin/master and prints only findings your diff
# ADDED. Without it you will chase warnings that were already in the file.
#
# Usage: validate.sh [addon-dir] [--vs-master]
set -uo pipefail
ADDON="${1:-}"
[ "${ADDON:-}" = "--vs-master" ] && { ADDON=""; set -- --vs-master; }
VS_MASTER=false
for a in "$@"; do [ "$a" = "--vs-master" ] && VS_MASTER=true; done
if [ -z "$ADDON" ]; then
mapfile -t _dirs < <(git diff --name-only origin/master...HEAD 2> /dev/null |
cut -d/ -f1 | sort -u | grep -vE '^\.' )
if [ "${#_dirs[@]}" -gt 1 ]; then
echo "several changed dirs: ${_dirs[*]}"
echo "pass one explicitly: validate.sh <addon-dir>"; exit 1
fi
ADDON="${_dirs[0]:-}"
fi
[ -z "$ADDON" ] && { echo "usage: validate.sh <addon-dir> [--vs-master]"; exit 1; }
git rev-parse --verify origin/master > /dev/null 2>&1 || {
echo "origin/master missing — run: git fetch origin master"; exit 1; }
export PYTHONDONTWRITEBYTECODE=1
echo "== validating $ADDON =="
fail=0
note() { printf ' %-13s %s\n' "$1" "$2"; }
# Shell: bash -n then shellcheck -x (follows sourced files, as CI does).
while IFS= read -r f; do
[ -f "$f" ] || continue
if ! out=$(bash -n "$f" 2>&1); then note "bash -n" "FAIL $f"; echo "$out" | sed 's/^/ /'; fail=1; fi
done < <(find "$ADDON" -type f \( -name '*.sh' -o -name 'run' -o -name 'finish' -o -name 'autostart' \) 2> /dev/null)
[ "$fail" -eq 0 ] && note "bash -n" "ok"
if command -v shellcheck > /dev/null 2>&1; then
sc=$(find "$ADDON" -type f \( -name '*.sh' -o -name 'autostart' -o -name 'run' -o -name 'finish' \) -print0 2> /dev/null |
xargs -0 -r shellcheck -x -f gcc 2>&1)
if [ -n "$sc" ]; then
note "shellcheck" "$(printf '%s\n' "$sc" | grep -c .) finding(s)"
printf '%s\n' "$sc" | sed 's/^/ /' | head -20
else note "shellcheck" "clean"; fi
fi
command -v hadolint > /dev/null 2>&1 && [ -f "$ADDON/Dockerfile" ] && {
hl=$(hadolint "$ADDON/Dockerfile" 2>&1)
[ -n "$hl" ] && { note "hadolint" "$(printf '%s\n' "$hl" | grep -c .) finding(s)"; printf '%s\n' "$hl" | sed 's/^/ /' | head -10; } || note "hadolint" "clean"
}
if [ -f "$ADDON/config.yaml" ]; then
# path passed as argv, never interpolated into Python source
python3 - "$ADDON/config.yaml" <<'PY' || { note "config.yaml" "FAIL parse"; fail=1; }
import yaml,sys
d=yaml.safe_load(open(sys.argv[1]))
print(' %-13s ok (version=%s, %d options)' % ('config.yaml', d.get('version'), len(d.get('options') or {})))
missing=[k for k in (d.get('options') or {}) if k not in (d.get('schema') or {})]
if missing: print(' %-13s options with no schema entry: %s' % ('WARN', missing)); sys.exit(0)
PY
command -v yamllint > /dev/null 2>&1 && {
yl=$(yamllint -f parsable "$ADDON/config.yaml" 2>&1 | grep -c .)
note "yamllint" "$yl finding(s) (compare with --vs-master)"
}
fi
while IFS= read -r f; do
python3 -m py_compile "$f" 2> /dev/null || { note "py_compile" "FAIL $f"; fail=1; }
done < <(find "$ADDON" -type f -name '*.py' 2> /dev/null)
$VS_MASTER && command -v npx > /dev/null 2>&1 && [ -f "$ADDON/CHANGELOG.md" ] && {
md=$(npx --yes markdownlint-cli2 "$ADDON/CHANGELOG.md" 2>&1 | grep -cE "CHANGELOG.md:[0-9]+")
note "markdownlint" "$md finding(s) in CHANGELOG (mostly pre-existing; lint is continue-on-error in CI)"
}
echo
echo "== CI requirements =="
if git diff --name-only origin/master...HEAD 2> /dev/null | grep -q "$ADDON/CHANGELOG.md"; then
note "CHANGELOG" "updated"
else
# This one IS gated: onpr_check-pr.yaml exits 1 without it.
note "CHANGELOG" "NOT UPDATED — this is the one CI hard-gate"; fail=1
fi
if git diff origin/master...HEAD -- "$ADDON/config.yaml" 2> /dev/null | grep -q '^+version:'; then
note "version" "bumped"
else
# Repo convention and required for the rebuild to be offered — but no workflow gates it,
# so this is a warning, not a failure.
note "version" "NOT bumped (convention; no rebuild will be offered) — not a CI gate"
fi
note "docker build" "NOT tested locally (dockerd unavailable) — CI is the only gate"
if $VS_MASTER; then
echo
echo "== findings ADDED by this diff (pre-existing ones filtered out) =="
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
git diff --name-only origin/master...HEAD -- "$ADDON" 2> /dev/null | while IFS= read -r f; do
git show "origin/master:$f" > "$tmp/base" 2> /dev/null || continue
# A missing linter must be a visible skip, not a silent "no new findings":
# its "command not found" error is identical for base and head, so comm would
# cancel it out and report a false clean.
case "$f" in
*.sh | *autostart | */run | */finish)
command -v shellcheck > /dev/null 2>&1 || { echo " $f: SKIPPED (shellcheck not installed)"; continue; }
cmd() { shellcheck -x -f gcc "$1" 2>&1 | sed 's/^[^:]*:[0-9]*:[0-9]*://'; } ;;
*.yaml | *.yml)
command -v yamllint > /dev/null 2>&1 || { echo " $f: SKIPPED (yamllint not installed)"; continue; }
cmd() { yamllint -f parsable "$1" 2>&1 | sed 's/^[^:]*//; s/^:[0-9]*:[0-9]*//'; } ;;
*Dockerfile)
command -v hadolint > /dev/null 2>&1 || { echo " $f: SKIPPED (hadolint not installed)"; continue; }
cmd() { hadolint "$1" 2>&1 | sed 's/^[^:]*//; s/^:[0-9]*//'; } ;;
*) continue ;;
esac
cp "$tmp/base" "$tmp/base_f"; b=$(cmd "$tmp/base_f" | sort)
a=$(cmd "$f" | sort)
new=$(comm -13 <(printf '%s\n' "$b") <(printf '%s\n' "$a") | grep -c .)
[ "$new" -gt 0 ] && { echo " $f: $new NEW finding(s)"; comm -13 <(printf '%s\n' "$b") <(printf '%s\n' "$a") | sed 's/^/ /' | head -5; }
done
echo " (nothing listed above = your diff introduced no new lint findings)"
fi
echo
[ "$fail" -eq 0 ] && echo "== local validation passed ==" || echo "== local validation FAILED =="
exit "$fail"