* fix(ai): let the fix step own config.yaml, and require the patch-counter bump The premise that the fix step cannot touch config.yaml turned out to be wrong, and the real problem was the opposite of what it looked like. config.yaml was already in scope — issue-fix.md lists it among the files the sweep reads and owns, and all three merged ai-fix PRs edited it. What they edited, though, was the one thing hard limit 2 forbade outright: PR #2970 qbittorrent version: "5.2.3.2" -> "5.2.3.3" PR #2912 bazarr version: "1.6.0.1" -> "1.6.0.2" Both bumped only the LOCAL PATCH COUNTER, leaving the upstream X.Y.Z alone — i.e. exactly the right thing, in direct violation of the written rule. Nothing enforces that rule (ai_guard_paths.sh only covers .github/ and .templates/), so it has been quietly contradicted by practice, and it also contradicts CLAUDE.md's own PR requirement to bump version. It matters because Supervisor will not offer a rebuild without the bump: a fix merged without one ships inert while the issue looks closed. That is the worst outcome available — worse than not fixing it. So the carve-out is narrowed to what addons_updater actually owns (the `upstream` field and the upstream X.Y.Z), and bumping the trailing .N is now required rather than forbidden, with the dot-not-hyphen trap called out (X.Y.Z-N reads as a semver pre-release and Supervisor treats it as older). Exotic version shapes — LSIO tags, dates, nightlies — are explicitly left alone rather than guessed at. Applied to all four places the rule is stated so they cannot drift: issue-fix.md, issue-execute-plan.md, CLAUDE.md, and pr-coderabbit.md — the last keeps the restriction, since it amends a PR whose single bump already covers it, but now says why instead of reading as a contradiction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ai): derive the patch counter from updater.json, not from version's shape Six review findings, all reproduced against the repo before accepting. Codex (P1) — the rule "increment the trailing .N" is wrong for most of this repo, because you cannot tell a local counter from an upstream component by looking at `version`. Checked all 134 add-ons: version == upstream_version (no counter, must APPEND .1): 82 version == upstream_version + .N (counter, INCREMENT): 8 version drifted from upstream (LEAVE ALONE): 36 no usable updater.json (LEAVE ALONE): 8 So the previous wording would have mutated updater-owned data on 82 add-ons: sonarr's 4.0.19.3001 IS the upstream version, and incrementing it to 4.0.19.3002 burns the identifier of a future real release; linkwarden's 2.16.0 would have become 2.16.1, indistinguishable from an upstream minor bump. updater.json's upstream_version is now the authority: append .1 when version equals it, increment only the digits that follow it, otherwise leave version alone. Validated by running the rule as written over every add-on — 0 violations of the invariant that a bumped version must still start with upstream_version. Copilot — there is no `upstream:` key in any config.yaml (0 of 134); upstream tracking lives in updater.json as upstream_repo / upstream_version. That was inherited text naming a field that does not exist, in all four places. Replaced with the real constraint: never edit updater.json. Copilot — the "a workflow step enforces them" headers over-claimed. Only limit 1 is machine-enforced (ai_guard_paths.sh); the rest ship silently if broken, which is worth saying plainly given limit 2 has been quietly contradicted by practice for months. Copilot — Outcome B produces a plan and no PR, so "say so in the pull request body" had no place to land. Now covers both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Repository Overview
This is a Home Assistant add-on repository containing 120+ Docker-based add-ons for the Home Assistant Supervisor. Each add-on is a self-contained directory with a Dockerfile, config schema, and S6-overlay init scripts. The repository uses GitHub Actions for CI/CD, linting, and automated upstream version tracking.
Add-On Directory Structure
Most add-ons follow this common layout, though exceptions exist (e.g. some archived add-ons use config.json instead of config.yaml, some add-ons have build.yaml instead of build.json or no build file at all, and not every add-on includes a rootfs/ tree):
addon_name/
├── config.yaml # HA add-on metadata, schema, ports, maps
├── build.json # Base Docker images per architecture (may be build.yaml, or absent)
├── Dockerfile # Multi-stage build (always uses shared .templates/ scripts)
├── updater.json # Upstream release tracking; required to enable automatic updates
├── CHANGELOG.md # Required; must be updated on every PR
└── rootfs/ # Optional; absent in some add-ons
└── etc/
├── cont-init.d/ # S6-overlay init scripts (numbered, run in order)
└── services.d/ # S6-overlay supervised services (some add-ons use
# s6-overlay v3 layout at etc/s6-overlay/s6-rc.d/ instead)
Dockerfile Convention
Most Dockerfiles follow this 6-section pattern (some add-ons deviate slightly, e.g. using a pinned upstream image directly instead of ARG BUILD_FROM):
- Build Image –
ARG BUILD_FROM+FROM ${BUILD_FROM} - Modify Image – S6 env vars, LSIO modifications via
ha_lsio.sh - Install Apps – Copy
rootfs/, download modules, install packages - Entrypoint – Set
S6_STAGE2_HOOK=/ha_entrypoint.sh - Labels – Standard OCI + HA labels from build args
- Healthcheck – curl-based check suppressed from nginx/apache logs
Shared build-time scripts are pulled from .templates/ at build time:
ha_automodules.sh– Downloads module scripts listed inARG MODULES=ha_autoapps.sh– Installs packages listed inENV PACKAGES=ha_entrypoint.sh– S6 stage-2 hook; launches the cont-init stack at container startha_lsio.sh– Patches LinuxServer.io base images for HA compatibilitybashio-standalone.sh– Bashio library for scripts outside Supervisor context
The ARG MODULES= line lists template scripts to download at build time (e.g., 00-banner.sh 01-custom_script.sh 00-smb_mounts.sh). Commonly-used modules in .templates/ (not exhaustive):
00-banner.sh– Print the add-on startup banner00-global_var.sh– Initialize global env vars from HA options00-local_mounts.sh– Mount local disks (localdisks option)00-smb_mounts.sh– SMB/CIFS network mount support00-deprecated.sh– Print a deprecation warning for add-ons superseded by official ones01-config_yaml.sh– Map HA options → app'sconfig.yaml01-custom_script.sh– Run user-provided custom scripts19-json_repair.sh– Validate/repair JSON config files90-disable_ingress.sh– Allow disabling HA ingress90-dns_set.sh– Configure custom DNS91-silent.sh– Reduce log verbosity91-universal_graphic_drivers.sh– GPU driver detection99-custom_script.sh– Run a userscript.shfrom the add-on config dir at startup
Other helper scripts in .templates/ used at build/run time: ha_automatic_packages.sh (resolve package names across distros), ha_entrypoint_modif.sh, 00-aaa_dockerfile_backup.sh, plus config.template/script.template/show_text_color (templates/assets copied into add-ons).
config.yaml Schema
Key fields in every add-on's config.yaml:
arch: [aarch64, amd64]
image: ghcr.io/alexbelgium/{slug}-{arch}
version: "X.Y.Z" # upstream version (format varies; see Versioning section)
ingress: true/false
ingress_port: 8000
map:
- addon_config:rw # /addon_configs/<hostname>/
- share:rw
- media:rw
- ssl
schema:
env_vars: # Allows arbitrary env var passthrough
- name: match(^[A-Za-z0-9_]+$)
value: str?
PUID: int
PGID: int
TZ: str?
networkdisks: str? # SMB mounts
localdisks: str? # Local disk mounts
The env_vars schema key enables the env-var passthrough mechanism. At runtime the 00-global_var.sh cont-init module reads /data/options.json and exports each key as an environment variable (writing to /.env and /etc/environment). ha_entrypoint.sh is the S6 stage-2 hook that launches the cont-init stack but does not itself perform the JSON-to-env conversion.
Versioning
Add-on versions in config.yaml closely follow the upstream release tag and do not conform to a single fixed format. Common patterns include:
X.Y.Z– plain upstream semver (e.g.0.137.0)X.Y.Z.N– upstream version with a local patch counter (e.g.0.6.26.2)- LSIO-style tags (e.g.
1.43.1.10611-1e34174b1-ls301) - Date-based versions (e.g.
2026.02.28) - Nightly builds (e.g.
nightly-20260321-397)
For the local patch counter, use a dot (X.Y.Z.N), not a hyphen. X.Y.Z-N parses as a semver pre-release tag, which Home Assistant Supervisor treats as older than plain X.Y.Z — it will not offer the update. New and updated add-ons should use .N; existing -N versions should be migrated to .N opportunistically (e.g. when that add-on is next touched), not as a standalone repo-wide sweep.
When an upstream version is bumped, update version in config.yaml. If the add-on's Dockerfile contains an ARG BUILD_UPSTREAM line, update that value too — it is the canonical place that records the upstream version at build time (it is not stored in build.json/build.yaml). Some add-ons do not use BUILD_UPSTREAM at all. The updater.json file tracks which upstream source/repo to monitor and records the last seen version.
updater.json Format
{
"source": "github", // github|dockerhub|pip|gitlab|bitbucket|helm_chart|...
"upstream_repo": "owner/repo",
"upstream_version": "1.2.3", // auto-populated by addons_updater
"slug": "addon_slug",
"last_update": "2025-01-01",
"github_beta": false,
"github_fulltag": false, // true = keep "v3.0.1-ls67", false = strip to "3.0.1"
"github_tagfilter": "", // require this text in release tag
"github_exclude": "", // exclude releases containing this text
"paused": false
}
CI/CD Workflows
On push to master (onpush_builder.yaml): Detects changed add-ons by watching config.* files, then sanitizes text files (Unicode spaces → ASCII, CRLF → LF) and restores shell script permissions. Auto-commits fixes with [nobuild] to skip rebuild loop.
On PR (onpr_check-pr.yaml): Validates CHANGELOG.md was updated, runs HA addon-linter, and tests Docker build for all changed add-ons.
Weekly (lint.yml): Runs Super-Linter across the repo, fixes shell formatting with shfmt (4-space indent), opens PRs for automated fixes.
Weekly (weekly_addons_updater): Runs the addons_updater container to bump add-on versions to match upstream.
Other automation workflows:
daily_README.yaml– Regenerates the rootREADME.mdadd-on table.weekly_crlftolf.yaml– Finds and fixes CRLF line endings repo-wide.weekly_reduceimagesize.yml– Compresses images and opens a PR with savings.weekly_stats.yaml/helper_stats_graphs.yaml– Refresh theStats/Stats2files and stat graphs.daily_stale.yml– Warns and closes stale issues/PRs.on_issues.yml/on_issues_ping_submitter.yml– Link issues to add-on READMEs and ping submitters.generate_stargazer_map.yml– Regenerates the stargazer map image.
Adding [nobuild] anywhere in a commit message skips the builder workflow.
AI issue triage
A tiered, Claude-powered pipeline triages and fixes add-on issues. It escalates
from cheap classification to a maintainer-approved automated fix, always leaving
manual actions with precedence. Prompts live in .github/prompts/, shared shell
in .github/scripts/.
| Workflow | Model | Trigger | Role |
|---|---|---|---|
on_issues_ai_triage.yaml |
Sonnet-low | issue opened (+ author reply, daily catch-up) | Tier 1: classify, dedupe, answer, ask for info; label ai-triage for real add-on bugs |
daily_ai_fix.yaml |
Opus 5-xhigh | daily 03:00 | Tier 2: diagnose the ai-triage batch; small+confident → ready PR (ai:fixed); else write a plan (ai:plan-pending) |
on_issue_approved.yaml |
Opus 5-high | maintainer adds ai:approved |
Tier 3: execute the approved plan → ready PR |
on_claude_mention.yml |
Sonnet-low | @claude by @alexbelgium |
Manual interactive override on any issue/PR |
on_pr_coderabbit.yml |
Sonnet-low | CodeRabbit reviews an ai-fix/* PR |
Once: fix or reply to review comments |
Control labels (ai:*) are workflow-owned. Key ones: ai-triage (queued for
the sweep), ai:plan-pending (plan posted, awaiting ai:approved), ai:fixed,
ai:upstream, ai:needs-info (a reporter reply re-runs tier 1 once),
ai:needs-human, ai:blocked (touched protected paths). no-ai opts an issue
out of the automated tiers but not the manual ones. Kill switch: set the
repo variable AI_DISABLED=true to pause every AI workflow with no file edits.
AI fixes must never touch .github/ or .templates/ (enforced by
ai_guard_paths.sh). They may edit config.yaml freely except the upstream
part of version, and must never edit updater.json — addons_updater owns
both. (There is no upstream: key in config.yaml; upstream tracking lives in
updater.json.) They must still bump the local patch counter so Supervisor
offers the rebuild —
without it the fix ships inert. The counter boundary comes from
updater.json's upstream_version, never from the shape of version: append
.1 when the two are equal (the common case — upstream versions here run to
four or five components), increment the trailing digits only when version is
upstream_version + .N, and otherwise leave version alone. This rule is
prompt-only, not machine-enforced.
Linting Rules
| Tool | Config | Key ignores |
|---|---|---|
| Hadolint | .hadolint.yaml |
DL3002, DL3006-9, DL3018 (no pinning required) |
| ShellCheck | .shellcheckrc |
SC2002 |
| Markdownlint | .markdownlint.yaml |
MD013 (line length), MD025, MD033, MD041 |
| shfmt | (CI enforced) | 4-space indent |
PR Requirements
- Update
CHANGELOG.mdin every changed add-on (CI validates this). - Bump
versioninconfig.yaml. - All linting must pass (Hadolint, ShellCheck, Markdownlint, HA addon-linter).
- Docker build must succeed for all declared architectures.
S6-Overlay Init Script Naming
Scripts in rootfs/etc/cont-init.d/ run in lexicographic order. Common numbering conventions (many add-ons use other prefixes too):
20-*– Directory/folder setup32-*– Nginx ingress configuration (e.g.32-nginx_ingress.sh)80-*– Application configuration90-*– Misc pre-startup tasks (ssl, vpn, custom run)99-*– Final startup / launch
User Customization
Add-ons support end-user customization without rebuilding the image (see the repo wiki). At startup, 99-custom_script.sh looks in the add-on's config directory for a user-provided script.sh (seeded from .templates/script.template) and executes it. Combined with the env_vars passthrough and the custom-script modules, this lets users inject commands and environment without forking the add-on.