Compare commits

...

20 Commits

Author SHA1 Message Date
alexbelgium
98c2fab87f fix(komga): ship an apparmor profile so local disks can be mounted
Without apparmor.txt Supervisor adds no apparmor security_opt, so Docker's
default profile applies and denies mount() and raw block device access:
mount reported 'cannot mount /dev/sda1 read-only' and the kernel logged
'/dev/disk/by-label/NAS: Can't open blockdev'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:31:49 +02:00
alexbelgium
cfe948c4d2 fix(komga): use a wall clock deadline for the readiness wait
An attempt count plus a per probe timeout stretched the wait to roughly twice
the advertised 15 minute ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:16:57 +02:00
alexbelgium
a80f16fe7d fix(komga): bound the nginx readiness probes and log an exhausted wait
Follow-up to #2960, which merged one commit before this landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:00:16 +02:00
Alexandre
b417da3980 fix(komga): restore add-on reverted by a transient ghcr.io login failure (#2960)
* fix(komga): restore add-on reverted by a transient ghcr login failure

The amd64 builder job failed at docker login (denied: denied) before any build
step ran, which tripped revert-on-failure. Re-running the same commit unchanged
succeeded and both arch images are published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(komga): poll komga directly instead of bashio::net.wait_for, clarify config path

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:44:54 +02:00
GitHub Actions
1133720b5b Revert "feat(komga): add Komga comics/manga server add-on with ingress (#2959)"
This reverts commit 4e043f7b94.
2026-08-11 16:06:11 +00:00
GitHub Actions
bc2ef6cbec Revert "GitHub bot: sanitize (spaces + LF endings) & chmod [nobuild]"
This reverts commit 9c36f9b480.
2026-08-11 16:06:11 +00:00
github-actions
9c36f9b480 GitHub bot: sanitize (spaces + LF endings) & chmod [nobuild] 2026-08-11 16:03:42 +00:00
Alexandre
4e043f7b94 feat(komga): add Komga comics/manga server add-on with ingress (#2959)
* feat(komga): add Komga comics/manga server add-on with ingress

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(komga): correct chmod path, PUID default and server-generated absolute urls

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(komga): drop webui, the addon linter rejects it when ingress is enabled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(komga): review fixes - init order, POSIX healthcheck, drop inert s6 vars

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:02:47 +02:00
Rodrigo Scomação do Nascimento
9e4e38535f fix(radarr): bump version so Home Assistant offers the rebuilt image (#2958)
* fix(radarr): bump version so HA offers the rebuilt ls313 image

The `6.3.0.10514` image tag was rebuilt and re-pushed on 2026-08-03 and
now ships LinuxServer.io ls313, while installations made before that date
still run the ls311 build they originally pulled.

Because build.json tracks the floating `lscr.io/linuxserver/radarr:*-latest`
tags, a rebuild silently changes the image contents without changing the
add-on version. The Supervisor decides whether an update exists purely by
comparing the `version` string in config.yaml against the installed one --
it does not compare image digests -- so an unchanged string means the
update is never offered and the new image is never pulled.

Add the local patch counter documented in CLAUDE.md to make the rebuild
visible to the Supervisor. Radarr itself is unchanged at 6.3.0.10514, so
updater.json keeps upstream_version as-is; the updater bot only rewrites
config.yaml when the upstream version moves, matching how lidarr
(3.1.0.4875 -> 3.1.0.4875.1) and bazarr (1.6.0 -> 1.6.0.2) already work.

The dotted `.1` form is required rather than `-1`: AwesomeVersion parses
`6.3.0.10514-1` as an unknown strategy and raises on comparison, whereas
`6.3.0.10514.1` compares as SimpleVer and sorts above `6.3.0.10514`.

* chore(sonarr,prowlarr): update to latest upstream releases

Sonarr   4.0.19.2997 -> 4.0.19.3001 (develop-4.0.19.3001-ls184, 2026-08-11)
Prowlarr 2.6.2.5517.9 -> 2.6.2.5534.9 (nightly-2.6.2.5534-ls9, 2026-08-08)

Both add-ons track a prerelease channel (github_beta), and their build.json
files pin the floating `-develop` / `-nightly` LinuxServer.io tags, so the
rebuild picks up the matching base image on merge.

Unlike radarr, neither add-on was affected by the stale-image problem: the
published images match the versions they claim (sonarr ships ls183 for
4.0.19.2997, prowlarr ships ls9 for 2.6.2.5517), so these are ordinary
version bumps that the weekly updater bot would otherwise pick up.

Sonarr also records the upstream version in ARG BUILD_UPSTREAM, updated
here to match. Prowlarr has no BUILD_UPSTREAM line.
2026-08-11 15:36:31 +02:00
dependabot[bot]
96380fdf5f Bump anthropics/claude-code-action from 1.0.183 to 1.0.187 (#2957)
Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.183 to 1.0.187.
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](be7b93b190...1623c36729)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.187
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 20:11:01 +02:00
github-actions
aff8931eaf GitHub bot : README updated 2026-08-10 17:17:43 +00:00
alexbelgium
69a6a1a62f Replace Stars evolution badge with token-based Star History chart 2026-08-10 17:27:02 +02:00
github-actions[bot]
9ffe457e5f Update stargazer map & cache 2026-08-10 14:58:50 +00:00
Alexandre
e219d241bc perf(stargazer-map): negative-cache blank locations for 90 days (#2954)
* perf(stargazer-map): negative-cache blank locations for 90 days

The lookup predicate treated a blank country as "not cached", so all 1689
blank rows of the 2652-row cache were re-queried on every weekly run --
~1689 GitHub API calls plus ~28 minutes of the polite time.sleep(1), to
re-derive the same blank answer. In a 89-user sample of those blanks,
87 (97.8%) simply have no public "location" on their profile, so the
lookups fail permanently rather than transiently.

Add a "last_checked" column to the CSV cache. A blank country is now only
re-queried once its check date is more than 90 days old; a known country is
still never re-queried; a user absent from the cache is queried immediately.
Rows from the old two-column file are treated as checked on 2026-08-10, so
the migration happens in the loader and the next run rewrites the CSV.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stargazer-map): treat a missing last_checked as never checked

Rows carried over from the two-column CSV are no longer backfilled to the
migration date; an absent, empty or non-ISO-date last_checked now reads as
"never checked" and is looked up on the next run, which stamps it. The first
run after merge therefore does the ~1689-user sweep once, and only after that
does the 90-day cadence take over.

Also addresses the review point that a corrupted last_checked in an already
three-column CSV would compare as "recent" under the lexicographic check and
suppress re-checks indefinitely: load_cache() now validates the cell with
datetime.date.fromisoformat and drops anything that is not a real date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(stargazer-map): add the missing save_cache docstring

Codacy flags C0116 on the touched function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(stargazer-map): capitalize load_cache docstring (pydocstyle D403)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(stargazer-map): cap expired rechecks at 200 per run to stagger them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stargazer-map): cap re-checks only, never the first sweep

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:24:38 +02:00
Alexandre
1b0969d537 fix(stargazer-map): resolve countries via ISO code instead of English name (Russia/Turkey/Ivory Coast were dropped) (#2956)
* fix(stargazer-map): resolve countries via ISO code, not English name

username_to_country() matched Nominatim's English display_name against
pycountry, but the two vocabularies disagree: pycountry.countries.lookup()
raises LookupError for "Russia", "Turkey" and "Ivory Coast" (its ISO names
are "Russian Federation", "Türkiye", "Côte d'Ivoire"). Those users were
silently recorded as unknown -- the committed cache has 963 users with a
country and zero Russia, so Russia rendered grey on the map.

Request addressdetails from Nominatim and read address.country_code
instead. No new dependency, same one request per user, and it drops the
reversed-component loop that could false-positive on a city or region
named like a country.

The return value is unchanged: still a pycountry .name string, so the CSV
cache and the ISO-3 rendering lookup are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stargazer-map): drop non-answer locations before geocoding

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:20:51 +02:00
Alexandre
a830293736 docs(stargazer map): credit OpenStreetMap for the geocoding (#2955)
The country lookup is done with Nominatim, i.e. OpenStreetMap data
(ODbL), which requires attribution wherever the derived data is shown.
The footnote credited only the GitHub profile. Add two lines crediting
Nominatim/OSM for the geocoding specifically -- the country shapes are
plotly's Natural Earth basemap, not OSM.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:04:07 +02:00
github-actions[bot]
4f7558050b Update stargazer map & cache 2026-08-10 13:53:06 +00:00
Alexandre
6194f26f00 feat(stargazer-map): readable log-scale map with baked-in stats (#2953)
* feat(stargazer-map): readable log-scale map with baked-in stats

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stargazer-map): count only current stargazers, honest caption wording

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(stargazer-map): show shares only, drop absolute per-country counts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:16:04 +02:00
Alexandre
39da9cf9b5 chore(skill): prefer reusing existing code for repo homogeneity (#2952)
* chore(skill): prefer reusing existing code for repo homogeneity

The standing rule already demanded the simplest solution; it said nothing
about where that solution should come from. A bespoke-but-simple mechanism
in one add-on is still a second way to solve a problem 120+ add-ons share.

- Standing rule: build out of what exists (.templates/ module, existing
  cont-init script, a sibling add-on's pattern), and match repo naming
  conventions when something new is genuinely needed.
- Step 3 (Plan): search for prior art before ranking mechanism levels; not
  reusing an existing mechanism now requires stating why.
- Step 5 (Simplify): reuse check alongside the existing ones — fold
  near-duplicates in, or justify the divergence in the PR body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skill): address Codex and CodeRabbit review feedback

- Prior-art search: the --include='*.sh' --include='config.yaml' allowlist
  missed the repo's main mechanisms. `ARG MODULES=` lives in Dockerfiles and
  s6 v3 services are extensionless `run` files; searching for MODULES= found
  6 files under the allowlist vs 129 (125 Dockerfiles) without it. Widened to
  --exclude-dir=.git and named the two file types explicitly.
- Reuse vs isolation: "fold a near-duplicate into the existing mechanism"
  contradicted traps.md:125, which requires a new numbered script rather than
  editing scripts shared by symlink with the webtop add-ons. Added the carve-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:10:36 +02:00
Alexandre
a04d818479 fix(ci): stop tier 1 wasting its turn budget; escalate max-turns after one retry (#2951)
* fix(ci): stop tier 1 wasting its turn budget; escalate max-turns after one retry

Now that classification actually runs, the 12-turn budget got its first real
exercise — and #2949 died on it. The budget was never the problem; how it was
spent was. Turn-by-turn from that run: 3 turns retrying Bash (not in
allowedTools, and failing against the bubblewrap sandbox that
allowed_non_write_users switches on), 6 hunting .templates/ha_entrypoint.sh and
ha_automodules.sh which are not in the sparse checkout, leaving 3 for the issue.

Fixed at the cause rather than by raising the cap, which stays at 12:

* .templates is now checked out. Most add-ons are thin wrappers around those
  shared scripts, so a large share of reports can only be explained by reading
  them — this makes triage more accurate, not merely faster. 184K, 25 files.
  It has to be added in TWO places: ai_triage_context.sh calls
  `git sparse-checkout set`, which REPLACES the list, so omitting it there
  would silently undo the workflow's checkout at exactly the wrong moment.

* The prompt now states the environment up front: three tools, no Bash, and
  precisely which paths exist on disk. The model cannot discover these cheaply
  — every probe costs a turn it then does not have for the analysis.

Separately, a max-turns death is NOT a workflow fault, but GATE 1 treated every
action failure as systemic and never escalated. So #2949 failed red, stayed
unlabelled, and the catch-up re-dispatched it daily forever — taking the first
of only five slots each time, since it sorts newest-first. It is now handled
like GATE 2: one retry, then ai:needs-human. Detected from the action's
execution_file, which is written even on failure. Warning rather than error,
because a red run per day for a per-issue condition is alarm fatigue, and the
outcome is recorded durably on the issue itself.

The two escalation sites are now one shared function, so they cannot drift.

Re-tested all 15 paths: max-turns across the three events, genuine action
failure with and without an execution file, and the full existing sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): make the max-turns probe fail closed on an unexpected file shape

Copilot: hit_max_turns scanned with `.[]?` and no root-type check. jq's `.[]?`
iterates the VALUES of an object, so if the action ever changed the execution
file's shape, {"result":{"subtype":"error_max_turns"}} would have matched —
downgrading a genuine workflow failure from a red run to a warning. That is the
silent-failure class this workflow exists to remove, arriving through the door
I had just built.

Reproduced: with the old filter that object matched; with `(type == "array")`
prepended it does not. Anything that is not the array we expect now falls
through to the loud path.

Verified: the real array shape is still detected and still escalates on the
second look; object-root, nested-object and non-JSON execution files all exit 1
red instead of being swallowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): fail loudly when escalation doesn't land; don't escalate a manual first look

Two CodeRabbit findings, both reproduced before accepting.

1. escalate_to_human suppressed `gh issue edit` with `|| true`, so it returned
   success even when ai:needs-human never landed. Both callers then exited 0
   reporting a hand-off that had not happened — and, having no label, the issue
   went straight back into the retry rotation the escalation existed to remove.
   The edit now propagates its status and callers exit 1 with an explicit error.
   `gh label create` stays best effort; the edit fails on its own if the label
   is genuinely missing. Verified that removing a label an issue does not carry
   is a no-op, so this cannot fail spuriously.

2. EVENT_NAME was doing duty as an attempt counter, but workflow_dispatch is
   BOTH the daily catch-up retry and the maintainer's manual re-triage — so a
   hand-dispatched FIRST attempt was escalated immediately.

   Rather than the suggested explicit retry state, the two are already
   distinguishable: the catch-up dispatches with GITHUB_TOKEN and arrives as
   github-actions[bot], a manual run as the maintainer. Confirmed against run
   metadata (catch-up 2026-08-10 = github-actions[bot]; manual 2026-07-27 =
   alexbelgium). is_automated_retry() keys on both, which makes "one retry then
   a human" literally true without new persistent state: a manual attempt that
   fails leaves the issue unlabelled, so the catch-up still gets its go.

Re-tested 15 paths including a stubbed `gh` failure at the escalation site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): raise max-turns to 25; no max-turns path may end in a silent green run

Three changes, one requested and two from an independent Codex review.

* --max-turns 12 -> 25, per the maintainer's updated call. The prompt preamble
  and comments were carrying the old number and are updated with it. The
  upfront optimisation stays: the earlier waste was 3 turns retrying an
  unavailable Bash and 6 hunting files outside the sparse checkout, and a
  bigger budget should buy analysis rather than more of that.

* Codex objected that the max-turns branch reintroduced the very failure class
  this workflow exists to prevent. It was right. On the SECOND look the outcome
  is durable (ai:needs-human), but on a FIRST attempt nothing was recorded
  anywhere except an annotation, so exiting 0 was a green run over triage that
  silently did not happen. Now the only exit 0 is the one where the escalation
  label actually landed; every other max-turns path is red. My "alarm fatigue"
  argument was overstated: escalation ends the rotation, so this costs at most
  one red run per problem issue, not one per day.

* Codex also flagged inferring the retry from github.actor as brittle — a
  re-run, a PAT- or App-issued dispatch, or a different maintainer all change
  it, and the false NEGATIVE (an automated retry never recognised as one, so it
  retries forever) is the dangerous direction. Replaced with an explicit
  `source` dispatch input that only the catch-up sets. Unknown provenance is
  now safe by construction because that path ends red rather than green.

Re-tested: max-turns across first look / manual dispatch / catch-up retry /
catch-up-with-failing-label / issue_comment, plus the full existing sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(ci): correct two triage comments the recent logic changes left stale

Comments only — no behaviour change, confirmed by diffing out comment lines
(nothing else moved) and re-running the behavioural suite to identical results.

* The prompt preamble still said "the turn budget is 12" and computed
  "leaving 3 for the actual issue" off it. The budget is 25 now. Reworded to
  keep the #2949 evidence, which is still true as history (3 turns retrying
  Bash, 6 hunting files outside the sparse checkout), while stating the
  current budget and why it is not licence to probe more.

* GATE 2 still said "A workflow_dispatch is the catch-up or a manual
  re-triage, i.e. the second look". That stopped being true when escalation
  moved to is_automated_retry(): only source=catchup counts as the second
  attempt, and a manual dispatch is a first look that deliberately does not
  escalate, leaving the issue unlabelled so the catch-up still gets its go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:55:32 +02:00
38 changed files with 3923 additions and 2753 deletions

View File

@@ -25,9 +25,14 @@ Triage first, then one of two paths:
Escalate mid-flight if a light task grows — touches a default, needs a new script or service, or
reveals a deeper problem.
**Standing rule:** ship the simplest solution that works. Complexity is bought only by a
**measurement** showing a concrete, user-visible cost on a real host — never by reasoning about
hypothetical performance.
**Standing rule:** ship the simplest solution that works, and build it out of what already
exists — a `.templates/` module, an existing cont-init script, the pattern a sibling add-on
already uses for the same problem. 120+ add-ons are maintained by one person: a homogeneous repo
where every add-on solves a problem the same way is worth more than a locally nicer bespoke
design. Prefer reusing or extending over adding a parallel implementation, and when you must add
something new, spell it the way the rest of the repo spells it (naming, option names, script
numbering, file layout). Complexity is bought only by a **measurement** showing a concrete,
user-visible cost on a real host — never by reasoning about hypothetical performance.
**Repo layout.** `alexbelgium/hassio-addons`; each add-on is a top-level directory. This skill is
checked in at `.claude/skills/hassio-addon-workflow/` (canonical copy). Set the skill root once,
@@ -73,7 +78,14 @@ before it costs a full analysis pass. Measurement methodology, gotchas, and real
## 3. Plan — choose the mechanism level, then Codex reviews it (full loop)
Rank mechanisms, pick the lowest (simplest) one that solves it, and state the choice in the plan:
Look for prior art first: grep `.templates/` and the other add-ons for something that already
solves this (`grep -rl "<knob or pattern>" --exclude-dir=.git .` — search everything, not just
`*.sh`: the mechanism may live in a `Dockerfile`'s `ARG MODULES=` or an extensionless s6 `run`
file). If an add-on already handles it, the plan is "do what that one does" — say so, and say why
the existing mechanism can't be reused if you're not reusing it.
Then rank mechanisms, pick the lowest (simplest) one that solves it, and state the choice in the
plan:
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_*`).
@@ -110,7 +122,13 @@ not just the happy path.
Before requesting review, check: did the diff stay at the ladder level chosen in step 3? Can this
be solved by deleting instead of adding? Is the fix bigger than what it fixes? How does it fail in
three years? Case studies of what happens when this check is skipped: `references/simplify.md`.
three years? And on reuse: does any hunk reimplement something `.templates/`, another script in
this add-on, or a sibling add-on already does — and if a future add-on hits this same problem,
will it find one way to solve it or two? Fold a near-duplicate into the existing mechanism, or
justify the divergence in the PR body — but never at the cost of an isolation rule
`references/traps.md` documents: scripts shared by symlink with the webtop add-ons take a new
numbered script, not an edit. Case studies of what happens when this check is skipped:
`references/simplify.md`.
## 6. Codex attacks the code (full loop only)

View File

@@ -3,17 +3,20 @@
Generate a static PNG world map colour-coded by the percentage of your
stargazers that come from each country. The script maintains a CSV
in ".github/stargazer_countries.csv" cache so that locations are only looked
up once (unless the country entry is blank).
up once. Blank answers are cached too and retried at most every RECHECK_DAYS,
no more than MAX_RECHECKS_PER_RUN re-checks per run.
"""
import csv
import datetime
import math
import os
import sys
import time
from collections import Counter
from pathlib import Path
import plotly.express as px
import plotly.graph_objects as go
import pycountry
import requests
from geopy.geocoders import Nominatim
@@ -25,12 +28,87 @@ GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") # provided by workflow
CSV_PATH = Path(".github/stargazer_countries.csv")
PNG_PATH = Path(".github/stargazer_map.png")
# ---- Cache policy -----------------------------------------------------------
# Most blank rows are permanent: the user simply has no public "location" on
# their profile. Re-asking GitHub and Nominatim for them every week is ~1700
# wasted requests per run, so a blank answer is cached too and only refreshed
# after RECHECK_DAYS. A row with no "last_checked" (i.e. written before the
# column existed) counts as never checked and is looked up once, which
# stamps it.
RECHECK_DAYS = 90
# Cap on how many already-checked rows one run may *re*-check, oldest first.
# It applies only to rows that carry a real last_checked date and have since
# expired: left uncapped, they all fall due on the same day and land as one
# spike. Rows that have never been checked -- new stargazers, and every row
# migrated from the pre-"last_checked" CSV -- are always looked up in full, so
# the first run after this lands still sweeps the whole backlog.
MAX_RECHECKS_PER_RUN = 200
# ---- Rendering theme --------------------------------------------------------
# Dark, opaque panel: GitHub does not swap the image between README themes, so
# a single background has to work in both. A dark canvas with a bright
# sequential ramp stays readable on light and dark pages alike.
BG = "#0d1117" # page / ocean
LAND = "#2b323c" # countries with zero stargazers (still visible)
BORDER = "#0d1117" # country outlines, same as background
FG = "#e6edf3" # primary text
MUTED = "#8b98a5" # secondary text
# Viridis truncated at 35 %: even a single stargazer gets a colour that is
# clearly distinct from the empty-land grey.
SCALE = ["#2c728e", "#21918c", "#35b779", "#90d743", "#fde725"]
# pycountry names that are too long / too formal for a top-5 list
SHORT_NAMES = {
"Russian Federation": "Russia",
"Korea, Republic of": "South Korea",
"Korea, Democratic People's Republic of": "North Korea",
"Iran, Islamic Republic of": "Iran",
"Taiwan, Province of China": "Taiwan",
"Viet Nam": "Vietnam",
"Moldova, Republic of": "Moldova",
"Bolivia, Plurinational State of": "Bolivia",
"Venezuela, Bolivarian Republic of": "Venezuela",
"Tanzania, United Republic of": "Tanzania",
"Syrian Arab Republic": "Syria",
}
HEADERS = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
GEOL = Nominatim(user_agent="gh-stargazer-map")
# Non-answers that Nominatim happily resolves to a real place: "Earth" is a
# town in Texas, "Remote" is a settlement in Oregon. Matched on the whole
# stripped, lowercased string only -- "Earth, TX" is someone's actual address
# and must still geocode.
JUNK_LOCATIONS = {
"127.0.0.1",
"/dev/null",
"anywhere",
"earth",
"everywhere",
"here",
"home",
"internet",
"localhost",
"mars",
"moon",
"n/a",
"none",
"nowhere",
"null",
"planet earth",
"remote",
"space",
"the internet",
"unknown",
"world",
"worldwide",
}
# -----------------------------------------------------------------------------
@@ -52,20 +130,50 @@ def fetch_stargazer_usernames():
return [s["login"] for s in github_paginated(url)]
def _checked_date(value):
"""Normalise a last_checked cell: a non-ISO-date value reads as never."""
value = (value or "").strip()
try:
datetime.date.fromisoformat(value)
except ValueError:
return ""
return value
def load_cache():
"""Map each username to (country, last_checked). Reads 2- and 3-column CSVs."""
if not CSV_PATH.exists():
return {}
with CSV_PATH.open(newline="", encoding="utf-8") as f:
return {row["username"]: row["country"] for row in csv.DictReader(f)}
return {
row["username"]: (
row["country"],
_checked_date(row.get("last_checked")),
)
for row in csv.DictReader(f)
}
def save_cache(cache):
"""Write the cache back as username,country,last_checked."""
CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
with CSV_PATH.open("w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["username", "country"])
for user, country in sorted(cache.items()):
w.writerow([user, country or ""])
w.writerow(["username", "country", "last_checked"])
for user, (country, last_checked) in sorted(cache.items()):
w.writerow([user, country or "", last_checked])
def needs_lookup(entry, cutoff):
"""True if this entry must be (re)queried. entry is None if absent."""
if entry is None:
return True # new stargazer
country, last_checked = entry
if country:
return False # a known country never changes here
if not last_checked:
return True # blank, never checked (pre-"last_checked" row)
return last_checked < cutoff # blank, and stale enough to retry
def username_to_country(login):
@@ -75,47 +183,223 @@ def username_to_country(login):
loc = (resp.json() or {}).get("location") or ""
if not loc.strip():
return ""
if loc.strip().strip(".!").lower() in JUNK_LOCATIONS:
return ""
try:
g = GEOL.geocode(loc, language="en", timeout=10)
g = GEOL.geocode(loc, language="en", addressdetails=True, timeout=10)
except Exception:
return ""
if not g or "display_name" not in g.raw:
return ""
# take the last comma-separated component that matches a country
for part in reversed(g.raw["display_name"].split(",")):
part = part.strip()
# Use the ISO code from the structured address: Nominatim's English display
# names ("Russia", "Turkey", "Ivory Coast") do not all match pycountry's ISO
# names ("Russian Federation", "Türkiye", "Côte d'Ivoire").
code = ((g.raw.get("address") or {}).get("country_code") or "") if g else ""
country = pycountry.countries.get(alpha_2=code.upper()) if code else None
return country.name if country else ""
def count_by_country(cache):
"""Counter of country name -> stargazers, ignoring blank locations."""
return Counter(country for country, _ in cache.values() if country)
def _log_ticks(lo, hi):
"""Colourbar ticks at ... 0.1, 0.3, 1, 3, 10, 30 ... spanning [lo, hi]."""
candidates = [m * 10**k for k in range(-3, 3) for m in (1, 3)]
ticks = [t for t in candidates if lo / 1.5 <= t <= hi]
return ticks or [hi]
def _fmt_pct(value):
"""1 -> '1%', 0.3 -> '0.3%' -- no trailing zeros."""
return f"{value:.2f}".rstrip("0").rstrip(".") + "%"
def build_figure(counts, total_stargazers):
"""Build the choropleth figure from a {country name: stargazers} mapping."""
by_iso = {}
for name, n in counts.items():
try:
country = pycountry.countries.lookup(part).name
return country
code = pycountry.countries.lookup(name).alpha_3
except LookupError:
pass
return ""
print("Skip unknown country:", name)
continue
# two spellings can resolve to the same ISO code, so accumulate
by_iso[code] = by_iso.get(code, 0) + n
iso = list(by_iso)
vals = [by_iso[k] for k in iso]
# count only what is actually drawn, so the caption matches the map
located = sum(vals) or 1
pcts = [v / located * 100 for v in vals]
lo, hi = (min(pcts), max(pcts)) if pcts else (1.0, 1.0)
def build_choropleth(percent_by_iso):
iso, vals = zip(*percent_by_iso.items())
fig = px.choropleth(
locations=list(iso),
locationmode="ISO-3",
color=list(vals),
color_continuous_scale="Greens",
range_color=(0, max(vals) if vals else 1),
# The distribution is heavily long-tailed (the top country holds ~200x the
# share of the tail), so a linear ramp collapses everything but a handful
# of countries into the first colour step. Colour on log10 of the share.
ticks = _log_ticks(lo, hi)
fig = go.Figure(
go.Choropleth(
locations=iso,
locationmode="ISO-3",
z=[math.log10(p) for p in pcts],
zmin=math.log10(lo) - 0.15, # keep the smallest share off the floor
zmax=math.log10(hi),
colorscale=SCALE,
marker_line_color=BORDER,
marker_line_width=0.5,
colorbar=dict(
title=dict(
text="share of located stargazers (log scale)",
font=dict(color=MUTED, size=13),
side="top",
),
orientation="h",
x=0.52,
y=0.02,
xanchor="center",
yanchor="bottom",
thickness=12,
len=0.34,
outlinewidth=0,
tickvals=[math.log10(t) for t in ticks],
ticktext=[_fmt_pct(t) for t in ticks],
tickfont=dict(color=MUTED, size=12),
),
)
)
fig.update_layout(
coloraxis_colorbar=dict(
title="% stargazers",
orientation="h", # <-- échelle horizontale
x=0.5, # <-- centré
y=0, # <-- tout en bas
xanchor="center",
yanchor="bottom",
thickness=15,
len=0.7, # <-- longueur de l'échelle, ajustable
fig.update_geos(
projection_type="natural earth",
showframe=False,
showcoastlines=False,
showland=True,
landcolor=LAND,
showocean=True,
oceancolor=BG,
showlakes=False,
bgcolor=BG,
lataxis_range=[-56, 84], # crop Antarctica, it is always empty
lonaxis_range=[-176, 186],
domain=dict(x=[0.0, 1.0], y=[0.04, 0.92]),
)
repo = REPO or "this repository"
caption = (
f"{total_stargazers:,} stargazers"
f" | {located:,} mapped to a country"
f" | {len(by_iso)} countries"
)
annotations = [
dict(
text=f"<b>Stargazers of {repo}</b>",
x=0.012,
y=0.985,
xref="paper",
yref="paper",
xanchor="left",
yanchor="top",
showarrow=False,
font=dict(color=FG, size=25),
),
dict(
text=caption,
x=0.012,
y=0.925,
xref="paper",
yref="paper",
xanchor="left",
yanchor="top",
showarrow=False,
font=dict(color=MUTED, size=15),
),
dict(
text="Countries in grey have no located stargazer.<br>"
"Location is read from the public GitHub profile,<br>"
"so the map covers the located subset only.<br>"
"Country lookup by Nominatim geocoding,<br>"
"data © OpenStreetMap contributors.",
x=0.988,
y=0.05,
xref="paper",
yref="paper",
xanchor="right",
yanchor="bottom",
align="right",
showarrow=False,
font=dict(color=MUTED, size=12),
),
]
# Top 5, laid out as two separate annotations (names, share) so each column
# stays aligned whatever the country name length -- HTML text in an SVG
# annotation collapses padding spaces, so a monospace table would not line
# up.
top = counts.most_common(5)
if top:
base_y = 0.40
columns = [
(
0.022,
"left",
"<br>".join(
f"{i}. {SHORT_NAMES.get(name, name)}"
for i, (name, _) in enumerate(top, 1)
),
FG,
),
(
0.215,
"right",
"<br>".join(f"{n / located * 100:.1f}%" for _, n in top),
FG,
),
]
annotations.append(
dict(
text="<b>TOP COUNTRIES</b>",
x=0.022,
y=base_y,
xref="paper",
yref="paper",
xanchor="left",
yanchor="top",
showarrow=False,
font=dict(color=MUTED, size=13),
)
)
annotations += [
dict(
text=text,
x=x,
y=base_y - 0.055,
xref="paper",
yref="paper",
xanchor=anchor,
yanchor="top",
align=anchor,
showarrow=False,
font=dict(color=color, size=15),
)
for x, anchor, text, color in columns
]
fig.update_layout(
width=1240,
height=680,
paper_bgcolor=BG,
plot_bgcolor=BG,
margin=dict(l=0, r=0, t=0, b=0),
annotations=annotations,
)
PNG_PATH.parent.mkdir(parents=True, exist_ok=True)
fig.write_image(str(PNG_PATH), scale=2)
return fig
def build_choropleth(counts, total_stargazers, path=PNG_PATH):
fig = build_figure(counts, total_stargazers)
path.parent.mkdir(parents=True, exist_ok=True)
# 1.5x of 1240x680 -> 1860x1020, sharp on HiDPI at README width without
# committing a multi-megabyte PNG every week.
fig.write_image(str(path), scale=1.5)
def main():
@@ -128,40 +412,46 @@ def main():
cache = load_cache()
# Determine which usernames need a lookup
to_lookup = [u for u in users if cache.get(u, "") == ""]
print(f"Need geocode for {len(to_lookup)} users")
# Determine which usernames need a lookup. Anything never checked -- a new
# stargazer, or a row migrated from the pre-"last_checked" CSV -- is looked
# up in full. Rows that were checked before and have since expired are
# rate-limited to MAX_RECHECKS_PER_RUN, oldest first, so the recurring
# RECHECK_DAYS wave arrives in slices rather than all at once.
now = datetime.date.today()
today = now.isoformat()
cutoff = (now - datetime.timedelta(days=RECHECK_DAYS)).isoformat()
due = [u for u in users if needs_lookup(cache.get(u), cutoff)]
never = [u for u in due if not cache.get(u, ("", ""))[1]]
expired = sorted(
(u for u in due if cache.get(u, ("", ""))[1]),
key=lambda u: (cache[u][1], u),
)
rechecks = expired[:MAX_RECHECKS_PER_RUN]
to_lookup = never + rechecks
print(
f"Need geocode for {len(to_lookup)} users "
f"({len(never)} never checked, {len(rechecks)} of {len(expired)} expired)"
)
for i, login in enumerate(to_lookup, 1):
country = username_to_country(login)
cache[login] = country
cache[login] = (country, today)
print(f"{i}/{len(to_lookup)}: {login:<20} -> {country}")
# Nominatim polite usage
time.sleep(1)
# Ensure all stargazers are in cache (even those with blank location)
for u in users:
cache.setdefault(u, "")
cache.setdefault(u, ("", today))
save_cache(cache)
# Build stats
countries = [c for c in cache.values() if c]
counts = Counter(countries)
total = sum(counts.values()) or 1
pct_by_country = {c: v / total for c, v in counts.items()}
# convert to ISO-3 for plotly
pct_by_iso = {}
for c, pct in pct_by_country.items():
try:
iso = pycountry.countries.lookup(c).alpha_3
pct_by_iso[iso] = pct * 100 # plotly wants numeric
except LookupError:
print("Skip unknown country:", c)
# The cache is never pruned, so it still holds users who have since
# unstarred. Keep them for future geocoding, but render only current stars.
counts = count_by_country({u: cache[u] for u in users})
print("Rendering PNG map…")
build_choropleth(pct_by_iso)
build_choropleth(counts, len(users))
print(
"Done – files saved:",
CSV_PATH.relative_to("."),

View File

@@ -77,7 +77,10 @@ if [ -n "$ADDON" ]; then
{
echo
echo "## Addon files: ${ADDON}/"
if ! git sparse-checkout set --no-cone .github/prompts .github/scripts "$ADDON" 2>&1; then
# `set` REPLACES the checkout list, so .templates has to be repeated here
# or the workflow's sparse-checkout of it is silently undone at this point
# — which is exactly the state that starved #2949 of its turn budget.
if ! git sparse-checkout set --no-cone .github/prompts .github/scripts .templates "$ADDON" 2>&1; then
# Swallowing this used to leave ADDON resolved with no files behind it,
# so the classifier could still reach high confidence off the addon
# name alone. Say so explicitly, in the same word Rule 2 already keys

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 404 KiB

View File

@@ -125,7 +125,7 @@ jobs:
- name: Analyse and fix
if: steps.batch.outputs.count != '0'
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
uses: anthropics/claude-code-action@1623c36729ac1cd5895198cded705a287de7db79 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Skip the OIDC -> Claude App token exchange. The scheduled path

View File

@@ -64,7 +64,7 @@ jobs:
fetch-depth: 1
- name: Run Claude Code
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
uses: anthropics/claude-code-action@1623c36729ac1cd5895198cded705a287de7db79 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# AI_PR_TOKEN, not GITHUB_TOKEN, so a PR Claude opens triggers CI.

View File

@@ -135,7 +135,7 @@ jobs:
- name: Execute the plan
if: steps.bundle.outputs.has_plan == 'true'
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
uses: anthropics/claude-code-action@1623c36729ac1cd5895198cded705a287de7db79 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Skip the OIDC -> Claude App token exchange, which 401s whenever

View File

@@ -41,6 +41,16 @@ on:
issue:
description: "Issue number to (re-)triage manually"
required: true
source:
# Explicit provenance, set only by the catch-up job below. Previously
# this was inferred from github.actor, which is brittle: a re-run, a
# dispatch via a PAT or App, or another maintainer all change it, and
# the dangerous direction is the false negative — an automated retry
# that is never recognised as one keeps retrying forever. An input the
# scheduler sets explicitly cannot drift with GitHub's actor semantics.
description: "Set to 'catchup' by the daily catch-up job; leave blank for a manual re-triage"
required: false
default: ""
permissions:
contents: read
@@ -131,9 +141,17 @@ jobs:
with:
fetch-depth: 1
persist-credentials: false
# .templates holds the shared build/runtime scripts (ha_entrypoint.sh,
# ha_automodules.sh, the cont-init modules) that nearly every add-on
# depends on, so a large share of reports can only be explained by
# reading them. Without it the classifier burned 6 of its turns on
# #2949 hunting for files that were not checked out, then died on
# max_turns. It is a small directory — cheaper to ship than to search
# for and not find.
sparse-checkout: |
.github/prompts
.github/scripts
.templates
sparse-checkout-cone-mode: false
- name: Build context bundle
@@ -148,7 +166,7 @@ jobs:
id: classify
if: github.event_name != 'issue_comment' || steps.claim.outputs.go == 'true'
continue-on-error: true
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
uses: anthropics/claude-code-action@1623c36729ac1cd5895198cded705a287de7db79 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Without this the action falls back to the OIDC -> Claude App token
@@ -190,10 +208,43 @@ jobs:
# actor=alexbelgium, a User.
allowed_bots: "github-actions"
show_full_output: true
# Stated up front, because a wrong guess about the environment costs
# turns the analysis then does not have. On #2949, under the earlier
# 12-turn budget, the model spent 3 turns retrying Bash and 6 hunting
# files outside the sparse checkout and died before reaching a
# verdict. The budget is 25 now, but it is meant to buy analysis, not
# more failed probing — keep this in step with --max-turns below.
prompt: |
Read /tmp/ai-triage/context.md, then follow the instructions in
.github/prompts/issue-classify.md exactly.
Before you start, two facts about this environment. Both are hard
limits, not preferences — working around them is not possible and
costs you turns you need for the analysis.
You have exactly three tools: Read, Glob and Grep. There is no
Bash. Do not try to run `find`, `ls`, `cat` or any other command;
those calls fail and are not retryable. Use Glob where you would
have used `find`, and Grep where you would have used `grep`.
This is a SPARSE checkout of a 100+ add-on monorepo. Only these
paths exist on disk — everything else is absent, and searching for
it will find nothing no matter how you phrase the search:
* .templates/ shared build and runtime scripts that most
add-ons rely on (ha_entrypoint.sh,
ha_automodules.sh, the cont-init modules)
* .github/prompts/, .github/scripts/
* the single add-on directory named in the context bundle, if it
was resolved — the bundle says which, or says UNRESOLVED
Other add-ons are NOT present. If the bundle says UNRESOLVED, no
add-on source is on disk at all: judge from the bundle alone and
set confidence accordingly rather than searching for the code.
You have a budget of 25 turns. The context bundle already contains
the issue, its comments, the add-on's config/Dockerfile/docs, its
recent commits and candidate duplicates — so read it first and
spend turns only on what it does not already answer.
Return your verdict as structured output. Do NOT comment on or
label the issue yourself.
# The model gets NO write capability of any kind — not Bash, not
@@ -214,7 +265,7 @@ jobs:
claude_args: |
--model claude-sonnet-5
--effort low
--max-turns 12
--max-turns 25
--allowedTools "Read,Glob,Grep"
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["owned","duplicate","needs-info","question","upstream-bug","addon-bug","feature-request"]},"addon":{"type":"string"},"confidence":{"type":"string","enum":["high","medium","low"]},"duplicate_of":{"type":"integer"},"labels":{"type":"array","items":{"type":"string"},"maxItems":2},"root_cause_hint":{"type":"string"},"comment":{"type":"string"}},"required":["verdict","confidence"]}'
@@ -225,11 +276,18 @@ jobs:
ISSUE: ${{ github.event.issue.number || inputs.issue }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
# Distinguishes the automated catch-up retry from a manual
# re-triage — see is_automated_retry below.
DISPATCH_SOURCE: ${{ inputs.source }}
CLASSIFY_OUTCOME: ${{ steps.classify.outcome }}
# Through env, never interpolated into the script body: this string
# is model output and "${{ }}" inline would splice it into the shell
# source itself.
STRUCTURED: ${{ steps.classify.outputs.structured_output }}
# Written by the action even when it fails (setExecutionFileOutputIfPresent
# runs in its catch block), which is what lets the max-turns check below
# work on exactly the runs that need it.
EXECUTION_FILE: ${{ steps.classify.outputs.execution_file }}
run: |
set -euo pipefail
mkdir -p /tmp/ai-triage
@@ -243,6 +301,67 @@ jobs:
gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-info >/dev/null 2>&1 || true
}
# Is this the automated second look, rather than a first attempt?
# EVENT_NAME alone is not enough: workflow_dispatch is BOTH the daily
# catch-up retry and the maintainer's manual re-triage, so keying on
# it alone escalates a hand-dispatched first attempt immediately.
# The catch-up therefore states its provenance explicitly via the
# `source` input. Inferring it from github.actor instead was rejected:
# a re-run, a PAT- or App-issued dispatch, or a different maintainer
# all change the actor, and the failure that matters is the false
# NEGATIVE — an automated retry not recognised as one would never
# escalate and would retry that issue forever.
# Unknown provenance is treated as "not the automated retry", which
# is safe here because every non-escalating max-turns path below ends
# in a red run rather than a silent green one.
is_automated_retry() {
[ "${EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${DISPATCH_SOURCE:-}" = "catchup" ]
}
# Hand the issue to a human and take it out of the retry rotation.
# Returns non-zero if the labels did not actually land — callers must
# treat that as a failure rather than reporting a hand-off that never
# happened, which would leave the issue unlabelled and back in the
# retry rotation it was supposed to leave.
escalate_to_human() {
# Best effort: the label usually exists, and `gh issue edit` fails
# on its own below if it does not.
gh label create ai:needs-human --repo "$REPO" --color ededed >/dev/null 2>&1 || true
# NOT suppressed with `|| true`. ai-triage and ai:needs-info come
# off in the same call: leaving ai-triage would keep an issue we
# just escalated sitting in tier 2's unattended queue, and leaving
# ai:needs-info would let a reporter reply silently re-trigger
# classification behind the human's back. Removing a label the
# issue does not carry is a no-op, so this cannot fail spuriously.
gh issue edit "$ISSUE" --repo "$REPO" \
--add-label ai:needs-human \
--remove-label ai-triage --remove-label ai:needs-info >/dev/null 2>&1
}
# Did the run die on its turn budget rather than on a workflow fault?
# The execution file is a JSON array of SDK messages; the terminal
# result object carries subtype "error_max_turns".
#
# This MUST fail closed: a false positive here downgrades a genuine
# workflow failure from a red run to a warning, which is the exact
# silent-failure class this workflow was rebuilt to remove. Hence the
# explicit `type == "array"` root check — without it `.[]?` happily
# iterates the VALUES of an object, so if the action ever changed the
# file's shape, {"result":{"subtype":"error_max_turns"}} would match
# and mask the failure. Anything that is not the array we expect is
# treated as "not max turns" and falls through to the loud path.
# The `?` and per-element type check keep a non-object element from
# aborting the step under set -e.
hit_max_turns() {
[ -n "${EXECUTION_FILE:-}" ] && [ -s "${EXECUTION_FILE:-}" ] || return 1
jq -e '(type == "array") and
any(.[]?;
(type == "object") and
(((.subtype? // "") == "error_max_turns") or
((.terminal_reason? // "") == "max_turns")))' \
"$EXECUTION_FILE" >/dev/null 2>&1
}
# GATE 1 — did the action itself run? This is checked BEFORE looking
# at the payload, because the action can fail *after* having written
# a valid structured output: the object would sail through the shape
@@ -260,6 +379,38 @@ jobs:
# explicit exit 1 the job would report success.
if [ "${CLASSIFY_OUTCOME:-}" = "failure" ]; then
restore_needs_info
# ...with one exception. Exhausting the turn budget is NOT a
# workflow fault: the action ran fine and this particular issue was
# just too tangled to finish inside the turn budget. Treating it as systemic
# meant #2949 failed red and stayed unlabelled, so the catch-up
# re-dispatched it every day forever — and being the newest issue
# it took the first of only five daily slots each time.
# So it is handled like GATE 2 below instead: one retry, then a
# human. Warning rather than error, because a red run per day for a
# per-issue condition is alarm fatigue, and the outcome is recorded
# durably on the issue itself rather than only in a run log.
# A green run is only ever justified once the outcome is recorded
# somewhere durable. On the automated second look that is the
# ai:needs-human label, and only if it actually landed. On a first
# attempt nothing is recorded anywhere but this annotation, so
# exiting 0 there would be precisely the "green run, work silently
# dead" state that left triage broken for weeks. It costs at most
# one red run per problem issue, not one per day, because the
# second look ends the retry rotation either way.
if hit_max_turns; then
if is_automated_retry; then
echo "::warning::second attempt for #$ISSUE also ran out of turns, handing it to a human"
if ! escalate_to_human; then
echo "::error::could not label #$ISSUE ai:needs-human — it is NOT escalated and stays in the retry rotation"
exit 1
fi
exit 0
fi
echo "::error::classification for #$ISSUE ran out of turns; leaving it for the catch-up to retry once, after which it goes to a human"
exit 1
fi
echo "::error::the Classify action failed for #$ISSUE — this is usually a workflow-level fault affecting every issue, so the issue is left untouched for a retry. See the Classify step."
exit 1
fi
@@ -276,29 +427,23 @@ jobs:
#
# Reaching here means the failure is specific to THIS issue — the
# model looked at it and produced nothing usable — so a retry is
# worth exactly one attempt. A workflow_dispatch is the catch-up or
# a manual re-triage, i.e. the second look, so hand it to a human
# rather than re-dispatching the same issue every day forever;
# ai:needs-human is in the catch-up exclusion search, so it drops out
# of the queue instead of starving newer issues behind it.
# worth exactly one attempt. Only a dispatch carrying source=catchup
# counts as that second attempt (is_automated_retry above); a manual
# workflow_dispatch is a first look and does NOT escalate, leaving
# the issue unlabelled so the catch-up still gets its own go. On the
# automated retry, hand it to a human rather than re-dispatching the
# same issue every day forever; ai:needs-human is in the catch-up
# exclusion search, so it drops out of the queue instead of starving
# newer issues behind it.
if [ ! -s "$F" ] || ! jq -e 'type == "object"' "$F" >/dev/null 2>&1; then
restore_needs_info
echo "::warning::no usable verdict produced for #$ISSUE"
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then
if is_automated_retry; then
echo "::warning::second attempt produced no verdict, handing #$ISSUE to a human"
gh label create ai:needs-human --repo "$REPO" --color ededed >/dev/null 2>&1 || true
# Drop the retry triggers in the same call. The catch-up search
# already excludes both, so this only bites on a MANUAL
# re-triage of an issue that still carries them — but there it
# matters: leaving ai-triage would keep an issue we just handed
# to a human sitting in tier 2's unattended fix queue, and
# leaving ai:needs-info would let a reporter reply silently
# re-trigger classification behind the human's back. The normal
# verdict path below already clears stale control labels; this
# keeps the escalation path consistent with it.
gh issue edit "$ISSUE" --repo "$REPO" \
--add-label ai:needs-human \
--remove-label ai-triage --remove-label ai:needs-info >/dev/null 2>&1 || true
if ! escalate_to_human; then
echo "::error::could not label #$ISSUE ai:needs-human — it is NOT escalated and stays in the retry rotation"
exit 1
fi
fi
exit 0
fi
@@ -484,7 +629,7 @@ jobs:
while IFS= read -r n; do
[ -n "$n" ] || continue
echo "re-dispatching tier 1 for #$n"
gh workflow run "AI issue triage" --repo "$REPO" -f issue="$n" || {
gh workflow run "AI issue triage" --repo "$REPO" -f issue="$n" -f source=catchup || {
echo "::error::could not dispatch classify for #$n"
FAILED=$((FAILED + 1))
}

View File

@@ -79,7 +79,7 @@ jobs:
- name: Address CodeRabbit comments
if: steps.claim.outputs.go == 'true'
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
uses: anthropics/claude-code-action@1623c36729ac1cd5895198cded705a287de7db79 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Skip the OIDC -> Claude App token exchange, which 401s whenever

View File

@@ -70,9 +70,15 @@ If you want to do add the repository manually, please follow the procedure highl
- %%STATS_AMD64%%
- %%STATS_AARCH64%%
### Stars evolution
### Star History
[![Star History Chart](https://api.star-history.com/svg?repos=alexbelgium/hassio-addons&type=Date)](https://star-history.com/#alexbelgium/hassio-addons&Date)
<a href="https://www.star-history.com/?type=date&repos=alexbelgium%2Fhassio-addons">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=alexbelgium/hassio-addons&type=date&theme=dark&legend=top-left&sealed_token=Ft6D4rx2V8l-M626J7uFACNWFJexZTQuLZvFi-nQ_FnbQ0KFnkzPBnnQdui7CREsxlWJ5rdTXvx5PVjpFxxQwump2HCc5SDviHt_iZPdJB3ckWEjXp0V3w" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=alexbelgium/hassio-addons&type=date&legend=top-left&sealed_token=Ft6D4rx2V8l-M626J7uFACNWFJexZTQuLZvFi-nQ_FnbQ0KFnkzPBnnQdui7CREsxlWJ5rdTXvx5PVjpFxxQwump2HCc5SDviHt_iZPdJB3ckWEjXp0V3w" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=alexbelgium/hassio-addons&type=date&legend=top-left&sealed_token=Ft6D4rx2V8l-M626J7uFACNWFJexZTQuLZvFi-nQ_FnbQ0KFnkzPBnnQdui7CREsxlWJ5rdTXvx5PVjpFxxQwump2HCc5SDviHt_iZPdJB3ckWEjXp0V3w" />
</picture>
</a>
## Add-ons provided by this repository

View File

@@ -70,9 +70,15 @@ If you want to do add the repository manually, please follow the procedure highl
- amd64: 93%
- aarch64: 7%
### Stars evolution
### Star History
[![Star History Chart](https://api.star-history.com/svg?repos=alexbelgium/hassio-addons&type=Date)](https://star-history.com/#alexbelgium/hassio-addons&Date)
<a href="https://www.star-history.com/?type=date&repos=alexbelgium%2Fhassio-addons">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=alexbelgium/hassio-addons&type=date&theme=dark&legend=top-left&sealed_token=Ft6D4rx2V8l-M626J7uFACNWFJexZTQuLZvFi-nQ_FnbQ0KFnkzPBnnQdui7CREsxlWJ5rdTXvx5PVjpFxxQwump2HCc5SDviHt_iZPdJB3ckWEjXp0V3w" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=alexbelgium/hassio-addons&type=date&legend=top-left&sealed_token=Ft6D4rx2V8l-M626J7uFACNWFJexZTQuLZvFi-nQ_FnbQ0KFnkzPBnnQdui7CREsxlWJ5rdTXvx5PVjpFxxQwump2HCc5SDviHt_iZPdJB3ckWEjXp0V3w" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=alexbelgium/hassio-addons&type=date&legend=top-left&sealed_token=Ft6D4rx2V8l-M626J7uFACNWFJexZTQuLZvFi-nQ_FnbQ0KFnkzPBnnQdui7CREsxlWJ5rdTXvx5PVjpFxxQwump2HCc5SDviHt_iZPdJB3ckWEjXp0V3w" />
</picture>
</a>
## Add-ons provided by this repository

14
komga/CHANGELOG.md Normal file
View File

@@ -0,0 +1,14 @@
## 1.26.1.2 (2026-08-12)
- Fix : local disks (`localdisks`) and SMB shares failed to mount with `cannot mount /dev/sdX read-only`. Without an `apparmor.txt` the add-on ran under Docker's default AppArmor profile, which denies `mount` and raw block device access. Ships the same profile as the other add-ons that mount disks
## 1.26.1.1 (2026-08-11)
- Bound the nginx readiness probes (`--connect-timeout` / `--max-time`) so a stalled connection cannot hang the wait, and log a warning when Komga has not answered within 15 minutes
## 1.26.1 (2026-08-11)
- Initial release, based on gotson/komga ([changelog](https://github.com/gotson/komga/releases))
- Ingress support : Komga is served on the `/komga` servlet context path, nginx prefixes it back with the ingress entry
- Supports local disks and SMB network shares for libraries (`localdisks` / `networkdisks` options)
- Supports extra environment variables via the `env_vars` option, see the [documentation](https://github.com/alexbelgium/hassio-addons/wiki/Add-Environment-variables-to-your-Addon-2)

121
komga/Dockerfile Normal file
View File

@@ -0,0 +1,121 @@
#============================#
# ALEXBELGIUM'S DOCKERFILE #
#============================#
# _.------.
# _.-` ('>.-`"""-.
# '.--'` _'` _ .--.)
# -' '-.-';` `
# ' - _.' ``'--.
# '---` .-'""`
# /`
#=== Home Assistant Addon ===#
#################
# 1 Build Image #
#################
ARG BUILD_FROM
ARG BUILD_VERSION
ARG BUILD_UPSTREAM="1.26.1"
FROM ${BUILD_FROM}
ENV BASHIO_VERSION=0.14.3
##################
# 2 Modify Image #
##################
# No S6_* tuning here : the upstream image ships no s6-overlay, so the vars the
# other addons set would be read by nobody
# Komga is served from a fixed servlet context path. Ingress strips its own
# prefix before forwarding, and Komga renders absolute asset urls, so nginx
# needs a stable subpath to prefix back. See rootfs/etc/nginx/servers/ingress.conf
ENV SERVER_SERVLET_CONTEXTPATH="/komga"
##################
# 3 Install apps #
##################
# Add rootfs
# Absolute paths on purpose : the upstream image sets WORKDIR /app, so the
# relative "find ." used by the other addons would miss /etc entirely
COPY rootfs/ /
RUN find /etc/cont-init.d /etc/services.d -type f \( -name "*.sh" -o -name "run" \) -print -exec chmod +x {} \;
# Uses /bin for compatibility purposes
# hadolint ignore=DL4005
RUN if [ ! -f /bin/sh ] && [ -f /usr/bin/sh ]; then ln -s /usr/bin/sh /bin/sh; fi && \
if [ ! -f /bin/bash ] && [ -f /usr/bin/bash ]; then ln -s /usr/bin/bash /bin/bash; fi
# Modules
ARG MODULES="00-banner.sh 00-global_var.sh 01-custom_script.sh 00-local_mounts.sh 00-smb_mounts.sh"
# Automatic modules download
COPY ha_automodules.sh /ha_automodules.sh
RUN chmod 744 /ha_automodules.sh && /ha_automodules.sh "$MODULES" && rm /ha_automodules.sh
# Manual apps
ENV PACKAGES="nginx"
# Automatic apps & bashio
COPY ha_autoapps.sh /ha_autoapps.sh
RUN chmod 744 /ha_autoapps.sh && /ha_autoapps.sh "$PACKAGES" && rm /ha_autoapps.sh
################
# 4 Entrypoint #
################
# The upstream image is a plain jre image without s6-overlay, so ha_entrypoint
# runs as pid 1 : it executes /etc/cont-init.d, then supervises /etc/services.d
COPY ha_entrypoint.sh /ha_entrypoint.sh
RUN chmod 777 /ha_entrypoint.sh
ENTRYPOINT ["/ha_entrypoint.sh"]
# Install bashio
COPY bashio-standalone.sh /usr/local/lib/bashio-standalone.sh
RUN chmod 0755 /usr/local/lib/bashio-standalone.sh
############
# 5 Labels #
############
ARG BUILD_ARCH
ARG BUILD_DATE
ARG BUILD_DESCRIPTION
ARG BUILD_NAME
ARG BUILD_REF
ARG BUILD_REPOSITORY
ARG BUILD_VERSION
ENV BUILD_VERSION="${BUILD_VERSION}"
LABEL \
io.hass.name="${BUILD_NAME}" \
io.hass.description="${BUILD_DESCRIPTION}" \
io.hass.arch="${BUILD_ARCH}" \
io.hass.type="addon" \
io.hass.version=${BUILD_VERSION} \
maintainer="alexbelgium (https://github.com/alexbelgium)" \
org.opencontainers.image.title="${BUILD_NAME}" \
org.opencontainers.image.description="${BUILD_DESCRIPTION}" \
org.opencontainers.image.vendor="Home Assistant Add-ons" \
org.opencontainers.image.authors="alexbelgium (https://github.com/alexbelgium)" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.url="https://github.com/alexbelgium" \
org.opencontainers.image.source="https://github.com/${BUILD_REPOSITORY}" \
org.opencontainers.image.documentation="https://github.com/${BUILD_REPOSITORY}/blob/main/README.md" \
org.opencontainers.image.created=${BUILD_DATE} \
org.opencontainers.image.revision=${BUILD_REF} \
org.opencontainers.image.version=${BUILD_VERSION}
#################
# 6 Healthcheck #
#################
# Komga is a jvm app, first boot builds the database : leave it time to settle
ENV HEALTH_PORT="25600" \
HEALTH_URL="/komga/"
HEALTHCHECK \
--interval=30s \
--retries=5 \
--start-period=180s \
--timeout=25s \
CMD curl -A "HealthCheck: Docker/1.0" -s -f "http://127.0.0.1:${HEALTH_PORT}${HEALTH_URL}" >/dev/null 2>&1 || exit 1

70
komga/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Home Assistant Add-on: Komga
Free and open source comics/mangas media server.
[Komga](https://komga.org) organizes your comics, mangas, BDs, magazines and ebooks, serves them
through a web reader, and exposes OPDS, Kobo sync and a REST API for third-party readers
(Tachiyomi/Mihon, Panels, Chunky, ...).
## About
- Browse and read CBZ, CBR, PDF and EPUB files from any browser
- Import metadata, edit series/books, build collections and read lists
- Multi-user, with per-user library restrictions and age ratings
- OPDS v1/v2, Kobo sync, and a documented REST API
## Installation
1. Add this repository to Home Assistant.
2. Install the **Komga** add-on.
3. Start the add-on and open it from the sidebar (ingress), or on port `25600` at
`http://homeassistant:25600/komga`.
4. Create the initial user account when the web interface asks for it.
5. Add a library pointing at your comics, for example `/media/comics` or `/share/comics`.
The first start takes longer than usual: Komga is a JVM application and builds its database and
search index on first boot.
## Configuration
| Option | Description |
|--------|-------------|
| `PUID` / `PGID` | Ownership applied to the add-on configuration directory. Defaults to `0` (root). |
| `TZ` | Timezone, e.g. `Europe/Paris`. |
| `localdisks` | Local disks to mount, e.g. `sda1` or a disk label. |
| `networkdisks` | SMB shares to mount, e.g. `//192.168.1.2/comics`. Mounted under `/mnt`. |
| `cifsusername` / `cifspassword` / `cifsdomain` | Credentials for the SMB shares. |
| `smbv1` | Allow the legacy SMBv1 protocol. |
| `env_vars` | Extra environment variables passed to Komga. See the [wiki](https://github.com/alexbelgium/hassio-addons/wiki/Add-Environment-variables-to-your-Addon-2). |
Most Komga settings can be passed through `env_vars` using the upstream naming, see the
[Komga configuration options](https://komga.org/docs/installation/configuration/). A common one:
- `JAVA_TOOL_OPTIONS` = `-Xmx1g` — cap the JVM heap on small machines.
`SERVER_SERVLET_CONTEXTPATH` and `SERVER_PORT` are reserved by the add-on: ingress is built
around the `/komga` path on port 25600, and overriding either breaks the sidebar panel.
## Ingress and URLs
Komga is served from the `/komga` subpath so that it works behind Home Assistant ingress:
- from the Home Assistant sidebar: ingress, no extra setup
- directly: `http://homeassistant:25600/komga`
External clients — OPDS readers, Kobo sync, Tachiyomi/Mihon, Panels — must use the direct
`http://homeassistant:25600/komga` url. Ingress is browser-session based, so those clients cannot
authenticate through it.
## Data
Komga's database, logs and search index live in `/config` inside the add-on, which Home Assistant
maps to this add-on's own configuration directory — `/addon_configs/<repository_id>_komga`, browsable
with the Filebrowser add-on. They survive add-on updates. Libraries stay where you put them, under
`/media`, `/share` or a mounted disk.
## Support
- [Komga upstream project](https://github.com/gotson/komga)
- [Add-on repository issues](https://github.com/alexbelgium/hassio-addons/issues)

68
komga/apparmor.txt Normal file
View File

@@ -0,0 +1,68 @@
#include <tunables/global>
profile komga_addon flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
capability chown,
capability dac_override,
capability dac_read_search,
capability fowner,
capability setgid,
capability setuid,
capability sys_chroot,
capability sys_admin,
file,
signal,
mount,
umount,
remount,
network udp,
network tcp,
network dgram,
network stream,
network inet,
network inet6,
network netlink raw,
network unix dgram,
# Entrypoint stack
/init ix,
/run/{s6,s6-rc*,service}/** ix,
/package/** ix,
/command/** ix,
/run/{,**} rwk,
/dev/tty rw,
/bin/** ix,
/usr/bin/** ix,
/usr/lib/bashio/** ix,
/etc/s6/** rix,
/run/s6/** rix,
/etc/services.d/** rwix,
/etc/cont-init.d/** rwix,
/etc/cont-finish.d/** rwix,
/init rix,
/var/run/** mrwkl,
/var/run/ mrwkl,
/dev/i2c-1 mrwkl,
# Files required
/dev/fuse mrwkl,
/dev/sda1 mrwkl,
/dev/sdb1 mrwkl,
/dev/nvme0 mrwkl,
/dev/nvme1 mrwkl,
/dev/mmcblk0p1 mrwkl,
/dev/* mrwkl,
/tmp/** mrkwl,
# Data access
/data/** rw,
# suppress ptrace denials when using 'docker ps' or using 'ps' inside a container
ptrace (trace,read) peer=docker-default,
# docker daemon confinement requires explict allow rule for signal
signal (receive) set=(kill,term) peer=/usr/bin/docker,
}

6
komga/build.json Normal file
View File

@@ -0,0 +1,6 @@
{
"build_from": {
"aarch64": "gotson/komga:1.26.1",
"amd64": "gotson/komga:1.26.1"
}
}

104
komga/config.yaml Normal file
View File

@@ -0,0 +1,104 @@
arch:
- aarch64
- amd64
description: Free and open source comics/mangas media server
devices:
- /dev/dri
- /dev/dri/card0
- /dev/dri/card1
- /dev/dri/renderD128
- /dev/vchiq
- /dev/video10
- /dev/video11
- /dev/video12
- /dev/video13
- /dev/video14
- /dev/video15
- /dev/video16
- /dev/ttyUSB0
- /dev/sda
- /dev/sdb
- /dev/sdc
- /dev/sdd
- /dev/sde
- /dev/sdf
- /dev/sdg
- /dev/nvme
- /dev/nvme0
- /dev/nvme0n1
- /dev/nvme0n1p1
- /dev/nvme0n1p2
- /dev/nvme0n1p3
- /dev/nvme1n1
- /dev/nvme1n1p1
- /dev/nvme1n1p2
- /dev/nvme1n1p3
- /dev/nvme2n1
- /dev/nvme2n1p1
- /dev/nvme2n1p2
- /dev/nvme2n3p3
- /dev/mmcblk
- /dev/fuse
- /dev/sda1
- /dev/sdb1
- /dev/sdc1
- /dev/sdd1
- /dev/sde1
- /dev/sdf1
- /dev/sdg1
- /dev/sda2
- /dev/sdb2
- /dev/sdc2
- /dev/sdd2
- /dev/sde2
- /dev/sdf2
- /dev/sdg2
- /dev/sda3
- /dev/sdb3
- /dev/sda4
- /dev/sdb4
- /dev/sda5
- /dev/sda6
- /dev/sda7
- /dev/sda8
- /dev/nvme0
- /dev/nvme1
- /dev/nvme2
image: ghcr.io/alexbelgium/komga-{arch}
ingress: true
ingress_entry: komga
init: false
map:
- addon_config:rw
- media:rw
- share:rw
name: Komga
options:
env_vars: []
PGID: 0
PUID: 0
panel_icon: mdi:book-open-page-variant
ports:
25600/tcp: 25600
ports_description:
25600/tcp: Web interface (path /komga)
privileged:
- SYS_ADMIN
- DAC_READ_SEARCH
schema:
env_vars:
- name: match(^[A-Za-z0-9_]+$)
value: str?
PGID: int
PUID: int
TZ: str?
cifsdomain: str?
cifspassword: str?
cifsusername: str?
localdisks: str?
networkdisks: str?
smbv1: bool?
slug: komga
udev: true
url: https://github.com/alexbelgium/hassio-addons/tree/master/komga
version: "1.26.1.2"

BIN
komga/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
komga/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,15 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# Komga stores its database, logs and search index in KOMGA_CONFIGDIR, which the
# upstream image sets to /config -- that is the addon_config mount
CONFIG_LOCATION="/config"
bashio::log.info "Config stored in $CONFIG_LOCATION"
mkdir -p "$CONFIG_LOCATION"
# Numbered 20- on purpose : it must sort after 00-global_var.sh, which is what
# exports PUID/PGID from the addon options. The upstream image sets neither, so
# the fallbacks only apply when the module is absent.
chown -R "${PUID:-0}:${PGID:-0}" "$CONFIG_LOCATION"

View File

@@ -0,0 +1,17 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
#################
# NGINX SETTING #
#################
declare ingress_interface
declare ingress_port
declare ingress_entry
ingress_port=$(bashio::addon.ingress_port)
ingress_interface=$(bashio::addon.ip_address)
ingress_entry=$(bashio::addon.ingress_entry)
sed -i "s/%%port%%/${ingress_port}/g" /etc/nginx/servers/ingress.conf
sed -i "s/%%interface%%/${ingress_interface}/g" /etc/nginx/servers/ingress.conf
sed -i "s|%%ingress_entry%%|${ingress_entry}|g" /etc/nginx/servers/ingress.conf

View File

@@ -0,0 +1,96 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
font/woff woff;
font/woff2 woff2;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}

View File

@@ -0,0 +1 @@
resolver 127.0.0.11 ipv6=off;

View File

@@ -0,0 +1,56 @@
# Run nginx in foreground.
daemon off;
# This is run inside Docker.
user root;
# Pid storage location.
pid /var/run/nginx.pid;
# Set number of worker processes.
worker_processes 1;
# Enables the use of JIT for regular expressions to speed-up their processing.
pcre_jit on;
# Write error log to Hass.io add-on log.
error_log /proc/1/fd/1 error;
# Load allowed environment vars
env HASSIO_TOKEN;
# Load dynamic modules.
include /etc/nginx/modules-enabled/*.conf;
# Max num of simultaneous connections by a worker process.
events {
worker_connections 512;
}
http {
include /etc/nginx/includes/mime.types;
log_format hassio '[$time_local] $status '
'$http_x_forwarded_for($remote_addr) '
'$request ($http_user_agent)';
access_log /proc/1/fd/1 hassio;
client_max_body_size 4G;
default_type application/octet-stream;
gzip on;
keepalive_timeout 65;
sendfile on;
server_tokens off;
tcp_nodelay on;
tcp_nopush on;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
include /etc/nginx/includes/resolver.conf;
include /etc/nginx/servers/*.conf;
}

View File

@@ -0,0 +1,60 @@
server {
listen %%interface%%:%%port%% default_server;
client_max_body_size 0;
# Home Assistant opens the ingress panel at <ingress_entry>/ and forwards it
# as / , but Komga only answers below its servlet context path (/komga), so
# bounce the panel there. absolute_redirect off keeps the Location relative
# to the HA host instead of nginx's own listen address.
location = / {
absolute_redirect off;
return 302 %%ingress_entry%%/komga/;
}
location / {
add_header Access-Control-Allow-Origin *;
proxy_connect_timeout 30m;
proxy_send_timeout 30m;
proxy_read_timeout 30m;
proxy_pass http://127.0.0.1:25600;
# Komga pushes live events over SSE (/komga/sse/v1/events), which must
# not be buffered or the UI stops refreshing until the buffer fills
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Spring redirects /komga to /komga/ ; the Location it produces is
# absolute against the upstream address, so rewrite it back onto the
# ingress path (the second rule covers an already relative Location).
absolute_redirect off;
proxy_redirect http://127.0.0.1:25600/ %%ingress_entry%%/;
proxy_redirect / %%ingress_entry%%/;
# Komga renders its index page with Thymeleaf @{...} link expressions,
# so every asset url and window.resourceBaseUrl carry the context path
# (/komga). Ingress strips its own prefix before forwarding, so the
# browser needs that prefix added back. Only text/html is rewritten
# (the nginx default for sub_filter_types) : the SPA derives its api
# origin and router base from resourceBaseUrl at runtime, so json
# responses and book pages stream through untouched.
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter "/komga" "%%ingress_entry%%/komga";
# The epub/divina reader fetches a Readium manifest whose links Komga
# builds with ServletUriComponentsBuilder.fromCurrentContextPath(), so
# they are fully absolute against the upstream address nginx talks to
# (http://127.0.0.1:25600/komga). Rewriting them to a root relative
# ingress path also fixes the scheme : Home Assistant may be served over
# https, and an absolute http:// link would be blocked as mixed content.
# Only the json/xml document types are added here, so book pages are
# never scanned.
sub_filter "http://127.0.0.1:25600/komga" "%%ingress_entry%%/komga";
sub_filter_types application/json application/webpub+json
application/divina+json application/opds+json
application/atom+xml;
}
}

View File

@@ -0,0 +1,17 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# ==============================================================================
# Same invocation as the upstream image entrypoint (gotson/komga), which is
# replaced by ha_entrypoint.sh so that cont-init.d and nginx can run too.
# SERVER_SERVLET_CONTEXTPATH is set in the Dockerfile, see ingress.conf.
bashio::log.info "Starting Komga (served on the /komga path, see the addon documentation)"
cd /app
exec java \
-Dspring.profiles.include=docker \
--enable-native-access=ALL-UNNAMED \
-jar application.jar \
--spring.config.additional-location=file:/config/

View File

@@ -0,0 +1,34 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# ==============================================================================
# Wait for komga to answer before nginx starts serving ingress. First boot
# builds the database, so leave a wide margin, but poll rather than call
# bashio::net.wait_for : bashio takes (port host timeout) while the bundled
# bashio-standalone.sh takes (host port timeout), and picking the wrong one
# would either fail instantly or block for the whole timeout.
# The per probe timeouts keep the 15 minute ceiling real : without them a
# half open connection would hang a single probe, and the loop, forever.
# A wall clock deadline, not an attempt count : a failed probe costs up to
# max-time on top of the sleep, so counting attempts would stretch the wait to
# roughly twice the advertised ceiling.
komga_ready=false
deadline=$((SECONDS + 900))
while [ "$SECONDS" -lt "$deadline" ]; do
if curl -sf --connect-timeout 2 --max-time 5 -o /dev/null "http://127.0.0.1:25600/komga/"; then
komga_ready=true
break
fi
sleep 5
done
# Deliberately not fatal : nginx serving a 502 tells the user something is wrong
# and starts working by itself once komga finally answers, while refusing to
# start would take ingress down for good after ha_entrypoint gives up retrying.
if [ "$komga_ready" != true ]; then
bashio::log.warning "Komga did not answer within 15 minutes. Starting NGinx anyway : ingress will return 502 until it does."
fi
bashio::log.info "Starting NGinx..."
exec nginx

9
komga/updater.json Normal file
View File

@@ -0,0 +1,9 @@
{
"github_beta": "false",
"last_update": "2026-08-11",
"repository": "alexbelgium/hassio-addons",
"slug": "komga",
"source": "github",
"upstream_repo": "gotson/komga",
"upstream_version": "1.26.1"
}

View File

@@ -1,4 +1,8 @@
## 2.6.2.5534.9 (2026-08-11)
- Update to latest version from linuxserver/docker-prowlarr (changelog : https://github.com/linuxserver/docker-prowlarr/releases)
- Upstream tag : nightly-2.6.2.5534-ls9
## 2.6.2.5517.9 (2026-08-02)
- Update to latest version from linuxserver/docker-prowlarr (changelog : https://github.com/linuxserver/docker-prowlarr/releases)
- Upstream tag : nightly-2.6.2.5517-ls9

View File

@@ -110,4 +110,4 @@ schema:
slug: prowlarr
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "2.6.2.5517.9"
version: "2.6.2.5534.9"

View File

@@ -1,10 +1,10 @@
{
"github_beta": "true",
"github_fulltag": "true",
"last_update": "2026-08-02",
"last_update": "2026-08-11",
"repository": "alexbelgium/hassio-addons",
"slug": "prowlarr",
"source": "github",
"upstream_repo": "linuxserver/docker-prowlarr",
"upstream_version": "nightly-2.6.2.5517-ls9"
"upstream_version": "nightly-2.6.2.5534-ls9"
}

View File

@@ -1,4 +1,8 @@
## 6.3.0.10514.1 (2026-08-11)
- Bump the addon version so Home Assistant offers the rebuilt image. The `6.3.0.10514` tag was rebuilt on 2026-08-03 and now ships LinuxServer.io `ls313` instead of the `ls311` build that existing installations pulled, but because the addon version string was unchanged the Supervisor saw nothing new and never offered the update. Radarr itself is still 6.3.0.10514 — only the LinuxServer.io base image moved
## 6.3.0.10514 (2026-07-13)
- Update to latest version from linuxserver/docker-radarr (changelog : https://github.com/linuxserver/docker-radarr/releases)

View File

@@ -108,4 +108,4 @@ schema:
slug: radarr_nas
udev: true
url: https://github.com/alexbelgium/hassio-addons/tree/master/radarr
version: "6.3.0.10514"
version: "6.3.0.10514.1"

View File

@@ -1,4 +1,8 @@
## 4.0.19.3001 (2026-08-11)
- Update to latest version from linuxserver/docker-sonarr (changelog : https://github.com/linuxserver/docker-sonarr/releases)
- Upstream tag : develop-4.0.19.3001-ls184
## 4.0.19.2997 (2026-08-08)
- Update to latest version from linuxserver/docker-sonarr (changelog : https://github.com/linuxserver/docker-sonarr/releases)

View File

@@ -16,7 +16,7 @@
ARG BUILD_FROM
ARG BUILD_VERSION
ARG BUILD_UPSTREAM="4.0.19.2997"
ARG BUILD_UPSTREAM="4.0.19.3001"
FROM ${BUILD_FROM}
##################

View File

@@ -110,4 +110,4 @@ schema:
slug: sonarr_nas
udev: true
url: https://github.com/alexbelgium/hassio-addons/tree/master/sonarr
version: "4.0.19.2997"
version: "4.0.19.3001"

View File

@@ -1,9 +1,9 @@
{
"github_beta": true,
"last_update": "2026-08-08",
"last_update": "2026-08-11",
"repository": "alexbelgium/hassio-addons",
"slug": "sonarr",
"source": "github",
"upstream_repo": "linuxserver/docker-sonarr",
"upstream_version": "4.0.19.2997"
"upstream_version": "4.0.19.3001"
}