Add repository brand icon endpoint - #5388
Conversation
Serve brand icons for integrations from HACS itself, now that custom integrations ship brand images in the repository and home-assistant/brands no longer accepts them: - Downloaded integrations are served from the local brand folder. - Other integrations are fetched from the repository content on GitHub and cached on disk, keyed by the version they were fetched for. - Repositories without a brand icon are redirected to the brands CDN.
There was a problem hiding this comment.
Pull request overview
This PR adds a new unauthenticated HTTP view in the HACS integration that serves repository brand icons (icon.png / dark_icon.png) to address missing icons for custom integrations since the HA 2026.3 brands changes. It serves icons from the local custom_components/<domain>/brand/ folder for downloaded integrations, otherwise fetches from raw.githubusercontent.com and caches results (including negative caching), with CDN/placeholder fallback behavior.
Changes:
- Introduces
HacsRepositoryIconViewwith local/remote icon serving, on-disk caching, and redirect fallbacks. - Registers the new view during HACS startup.
- Adds a new test suite for the view plus API-usage snapshots.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
custom_components/hacs/brands.py |
New icon-serving endpoint with local/remote fetch logic and caching. |
custom_components/hacs/__init__.py |
Registers the new icon view at startup. |
tests/test_brands.py |
Adds tests covering local serve, remote fetch/cache, redirects, and validation cases. |
tests/snapshots/api-usage/tests/test_brandstest-*.json |
Adds API usage snapshots for the new tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ling Address review feedback: - Local and cached icons go through the same PNG magic-byte and size validation as downloaded ones. - The cache filename now percent-encodes the ref so distinct refs can not collide. - async_download_file already returns None on failure, so the try/except around it was dead code.
|
Tested this on real hardware today. The core of it works, including the non-downloaded case that #937 could not cover. Setup, and its caveatI cherry-picked Please read the anomaly below with that base in mind — repository-data hydration is exactly the kind of thing that could differ between 2.0.5 and Results
All four downloaded integrations ship their icon in-repo and are not in The One observation on the two failuresBoth fell back without attempting a download. The cache directory after the run contains only the success: Since
I suspect this is a 2.0.5 hydration artefact rather than anything wrong with this PR, since the ref-selection code never runs. Flagging it in case it points at something worth a guard, or in case it reproduces on Verdict from hereWorks as designed on the cases that matter, and covers the gap that stalled the frontend-only approach. Would be good to see this land — |
|
I went looking for @slettmayer's anomaly — the two not-downloaded repositories that fell back to the CDN without attempting a download and without leaving a ref = repository.data.last_version or repository.data.default_branch
cache_ref = repository.data.last_version or repository.data.last_commit or ref
if ref is None or cache_ref is None:
return self._fallback_response(repository, filename, token=token)That guard returns before The reason those three fields are empty is in EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (...) # no last_version,
# no default_branch,
# no last_commit
EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
("default_branch", None),
("last_commit", None),
("last_version", None),
...
)Only downloaded repositories persist a version. Every not-downloaded one comes back from storage with all three unset — which is precisely the population this view was added to serve.
That second case is the one that stings, because a custom repository is by definition not in That would explain the split in the test matrix without needing 2.0.5 to be at fault: Worth noting the failure is sticky rather than transient, because the early return takes the default Suggested fix
- ref = repository.data.last_version or repository.data.default_branch
+ ref = repository.data.last_version or repository.data.default_branch or DEFAULT_REF
cache_ref = repository.data.last_version or repository.data.last_commit or ref
- if ref is None or cache_ref is None:
- return self._fallback_response(repository, filename, token=token)with A regression test, in the style of the ones already there: async def test_icon_view_remote_without_any_stored_ref(
hass: HomeAssistant,
setup_integration: Generator,
response_mocker: ResponseMocker,
) -> None:
"""Test serving a repository that has no version information at all."""
repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME)
repository.data.last_version = None
repository.data.default_branch = None
repository.data.last_commit = None
response_mocker.add(
"https://raw.githubusercontent.com/hacs-test-org/integration-basic"
"/main/custom_components/example/brand/icon.png",
MockedResponse(content=ICON_CONTENT),
)
response = await _get_icon(hass, REPOSITORY_ID, "icon.png")
assert response.status == 200
assert response.body == ICON_CONTENT
assert os.path.exists(hass.config.path(".storage/hacs.icons/1296269-main-icon.png"))It fails on the branch as it stands with I ran this on the min-supported leg (HA 2025.3.0, Python 3.13, Happy to open this as a PR against your branch if that's easier than folding it in yourself, @Niek — your call, it's your PR and I don't want to get in the way of it. |
|
Thanks for digging into this! I pushed a minimal fix that falls back to main when no repository ref has been stored yet, plus the suggested regression test for that startup/persisted state. All 444 backend tests pass. |
|
Thanks @Niek — checked out Since I'd only argued the empty-ref state from reading the export lists, here it is observed. I migrated one of my own installs from hand-copied files to a HACS download today, which meant I had Before, added as a custom repository and known to HACS but not downloaded: After HACS downloaded it, same entry, nothing else changed: All three refs appear only on being downloaded, exactly as So the icon it would have been redirected to was the CDN placeholder, for a domain the CDN will never have. Worth noting for anyone measuring this: that URL answers 200, not 404 — it is a ~3 KB placeholder, byte-identical to what a deliberately nonsense domain returns. Comparing status codes tells you nothing; comparing hashes does. Nothing needed on this PR, it already handles it. Just closing the loop on the one open question. |
|
This lines up with the endpoint design sketched in hacs/frontend#937 — HACS-side endpoint, authenticated fetch, cached — and it addresses the objection that stalled the simpler approach there: the core brands proxy only covers installed integrations, so a repository that hasn't been downloaded yet has nothing local to serve. One supporting detail for doing this server-side rather than as a frontend fallback: the CDN's — so a client-side Happy to test against a live instance if that's useful. I have exactly the non-grandfathered case: HA 2026.8.1 / HACS 2.0.5, custom repository with bundled |
Proposed change
Adds an authenticated endpoint that serves brand icons for repositories HACS knows about:
Since Home Assistant 2026.3, custom integrations ship brand images in
custom_components/<domain>/brand/, andhome-assistant/brandsno longer accepts images for custom integrations (announcement). The HACS dashboard still builds icon URLs against the brands CDN, so custom integrations that are not grandfathered into the CDN show a placeholder (#5171, #5223, #5179).The core brands proxy only covers installed integrations, which was the main concern raised against switching the frontend to it in hacs/frontend#937. This implements the HACS repository endpoint design discussed there:
brands/access_token.raw.githubusercontent.com. Both standard repository layouts andcontent_in_rootare supported.last_commitas their cache identity, so a new commit refreshes the icon.dark_icon.pngfalls back toicon.pngwhile preserving the access token.Repositories without a usable icon ultimately redirect to the brands CDN, preserving grandfathered images and its placeholder behavior. The update entity picture is intentionally unchanged; #5228 / #5339 cover that separately.
Frontend companion PR: hacs/frontend#945.
Release follow-up
The integration currently pins frontend release
20250128065759inscripts/install/frontend. To ship this end to end:hacs/frontendrelease containing Use the HACS repository icon endpoint for dashboard icons frontend#945.FRONTEND_VERSIONinhacs/integrationto that release.The pin cannot be updated in this PR until that frontend release exists.
Related issues and PRs: fixes #5171, #5179, and #5223 together with the frontend PR; alternative to hacs/frontend#937 and hacs/frontend#929, which only cover installed integrations.
Checklist
content_in_root, branch cache refresh, transient failures, streamed size limits, dark fallback, and config-entry reloads.