From e60ed72206be05f4ee934906f4401ce879d39d58 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Jul 2026 09:15:03 +0000 Subject: [PATCH 1/4] chore(release): 1.7.14.1 [skip ci] --- custom_components/googlefindmy/const.py | 2 +- custom_components/googlefindmy/manifest.json | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/googlefindmy/const.py b/custom_components/googlefindmy/const.py index a4923b362..8742a59b0 100644 --- a/custom_components/googlefindmy/const.py +++ b/custom_components/googlefindmy/const.py @@ -31,7 +31,7 @@ # NOTE: no ": str" annotation on purpose -- semantic-release's version_variables # regex only matches `NAME = "x"`, not `NAME: str = "x"`. Re-adding the annotation # would silently skip this file on the automated version bump. -INTEGRATION_VERSION = "1.7.14.0" +INTEGRATION_VERSION = "1.7.14.1" # -------------------------------------------------------------------------------------- # Shared textual constants diff --git a/custom_components/googlefindmy/manifest.json b/custom_components/googlefindmy/manifest.json index de6aa1556..736fbfabb 100644 --- a/custom_components/googlefindmy/manifest.json +++ b/custom_components/googlefindmy/manifest.json @@ -35,5 +35,5 @@ "selenium>=4.25.0", "undetected_chromedriver>=3.5.5" ], - "version": "1.7.14.0" + "version": "1.7.14.1" } diff --git a/pyproject.toml b/pyproject.toml index 5c6b79fb8..705ddf4a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ name = "googlefindmy-ha" # script/stamp_version.py, so on those lines the hand-tag/stamp path is primary, # not a fallback. Canonical anchor: # custom_components/googlefindmy/AGENTS.md ("Version bump touches three files"). -version = "1.7.14.0" +version = "1.7.14.1" description = "Home Assistant integration for Google's Find My Device network." readme = "README.md" license = "MIT" From 3c6cbeccacde666e54fa833df2fcea278481dbed Mon Sep 17 00:00:00 2001 From: Jens Leinenbach <1786119+jleinenbach@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:30:13 +0200 Subject: [PATCH 2/4] fix(ci): accept v-prefixed release tags in release stamping (#1205) * fix(ci): accept v-prefixed release tags in release stamping BSkando tags releases vX.Y.Z while jleinenbach tags X.Y.Z[.N]; the shared release-stamp.yml guard rejected any leading `v`, so publishing v1.7.14 on BSkando failed at "Validate published tag" and never ran the stamp, commit, push or HACS ZIP upload. The published release shipped with no googlefindmy.zip asset (broken HACS installs). Strip an optional single leading `v` at the workflow boundary (VERSION="${TAG_NAME#v}") and validate/stamp that v-less VERSION; the full TAG_NAME (with any `v`) still addresses the git tag and release asset (checkout ref, rev-list refs/tags, gh release upload). VERSION_RE stays version-only, a version literal never carries a `v`. release.yml's REAL_TIP gate strips `v` too so a genuine v-prefixed line tip is recognised as a version rather than blanked as foreign. Adds mutation-sharp tests for the tag->version mapping and locks the REAL_TIP strip into the workflow; the byte-parity guard test is unaffected (the regex pattern is unchanged, only its input variable). Co-Authored-By: Claude Opus 4.8 * style(test): apply ruff format to test_stamp_version Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/release-stamp.yml | 24 ++++--- .github/workflows/release.yml | 15 +++-- script/stamp_version.py | 24 ++++--- tests/test_stamp_version.py | 97 +++++++++++++++++++++++++---- 4 files changed, 127 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release-stamp.yml b/.github/workflows/release-stamp.yml index 91f926221..b24276d76 100644 --- a/.github/workflows/release-stamp.yml +++ b/.github/workflows/release-stamp.yml @@ -66,25 +66,35 @@ jobs: python-version: "3.13" - name: Validate published tag - # Early, human-readable abort. The canonical regex (SSOT) lives in - # script/stamp_version.py (VERSION_RE); this guard mirrors it byte-for-byte - # (locked by tests/test_stamp_version.py). The shape is deliberately PEP + # Early, human-readable abort. The published git tag may carry a leading + # `v` (BSkando tags `vX.Y.Z`) or not (jleinenbach tags `X.Y.Z[.N]`) -- the + # shared release tooling must honour both fork conventions. The code + # literals, however, are ALWAYS PEP 440 versions with no `v`, so we strip + # an optional single leading `v` here and validate/stamp that VERSION. + # The canonical regex (SSOT) lives in script/stamp_version.py (VERSION_RE); + # this guard mirrors it byte-for-byte (locked by tests/test_stamp_version.py) + # and is applied to the stripped VERSION. The shape is deliberately PEP # 440-broad (bN/aN betas, four-segment .N) so hand-picked maintenance tags # stamp cleanly. semantic-release cannot *propose* for those lines (see # release.yml Workflow A, which opens a hand-tag draft there instead); this # step stamps the exact published tag regardless. Keep both regexes in sync. run: | set -euo pipefail - if ! printf '%s' "$TAG_NAME" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?([ab][0-9]+)?$'; then - echo "::error::tag_name '$TAG_NAME' violates tag_format='{version}'; expected X.Y.Z[.N][bN], no 'v' prefix" + # Strip at most one leading `v` (POSIX ${VAR#v}); a no-op for v-less + # tags. VERSION is the v-less value written into the code literals; the + # full TAG_NAME (with any `v`) still addresses the git tag / release asset. + VERSION="${TAG_NAME#v}" + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?([ab][0-9]+)?$'; then + echo "::error::tag_name '$TAG_NAME' (version '$VERSION') violates tag_format; expected X.Y.Z[.N][bN] with an optional leading 'v'" exit 1 fi - echo "validated tag=$TAG_NAME" + echo "validated tag=$TAG_NAME version=$VERSION" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" - name: Stamp version into the three literals run: | set -euo pipefail - python script/stamp_version.py --version "$TAG_NAME" + python script/stamp_version.py --version "$VERSION" - name: Commit the stamp run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a75069b5..9d79b1376 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,13 +98,18 @@ jobs: # (safe). Real PSR errors still surface loudly in the dry-run preview # step above (`--noop version`), which is the diagnostic path. REAL_TIP="$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true)" + # Strip an optional single leading `v` (BSkando tags `vX.Y.Z`, + # jleinenbach `X.Y.Z[.N]`) so a genuine `v`-prefixed line tip is + # recognised as a version below rather than blanked as foreign. + REAL_TIP="${REAL_TIP#v}" # Gate REAL_TIP against the canonical PEP 440 version shape (SSOT: # script/stamp_version.py VERSION_RE, byte-mirrored in release-stamp.yml). - # A foreign, non-version tag (e.g. `set-release-tag`, `nightly`, `v2`) - # is not a valid line tip: it must NOT poison the PSR_LAST != REAL_TIP - # comparison below and spuriously route to the hand-tag draft. PEP 440 - # beta/four-segment tags DO match VERSION_RE, so they are kept and still - # trigger MODE=hand on a real stale-base mismatch -- the intended guard. + # A foreign, non-version tag (e.g. `set-release-tag`, `nightly`, `v2` + # -> `2` after the v-strip) is not a valid line tip: it must NOT poison + # the PSR_LAST != REAL_TIP comparison below and spuriously route to the + # hand-tag draft. PEP 440 beta/four-segment tags DO match VERSION_RE, so + # they are kept and still trigger MODE=hand on a real stale-base + # mismatch -- the intended guard. if [ -n "$REAL_TIP" ] && ! printf '%s' "$REAL_TIP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?([ab][0-9]+)?$'; then echo "::notice::ignoring non-PEP-440 line tip '${REAL_TIP}' for PSR routing (not a version tag)." REAL_TIP="" diff --git a/script/stamp_version.py b/script/stamp_version.py index 8cb7b7f30..9ffb485a8 100755 --- a/script/stamp_version.py +++ b/script/stamp_version.py @@ -23,11 +23,14 @@ python script/stamp_version.py --version 1.7.14 python script/stamp_version.py --version 1.7.14b1 --repo-root /path/to/repo -The tag is validated against the ``tag_format = "{version}"`` convention -(no ``v`` prefix): ``X.Y.Z`` with an optional ``.N`` fourth segment followed by -an optional ``bN``/``aN`` prerelease (covers 1.7.14, 1.7.14b1, 1.7.13.1, -1.7.13.1b1). The segment order is PEP 440: the numeric release tuple precedes -the prerelease, so ``1.7.14.1b1`` is valid but ``1.7.14b1.1`` is not. +The ``--version`` argument is a *version* and never carries a ``v`` prefix: +``X.Y.Z`` with an optional ``.N`` fourth segment followed by an optional +``bN``/``aN`` prerelease (covers 1.7.14, 1.7.14b1, 1.7.13.1, 1.7.13.1b1). The +segment order is PEP 440: the numeric release tuple precedes the prerelease, so +``1.7.14.1b1`` is valid but ``1.7.14b1.1`` is not. The *published git tag* may +carry a leading ``v`` (BSkando tags ``vX.Y.Z``, jleinenbach ``X.Y.Z[.N]``); +release-stamp.yml strips that optional ``v`` before calling this script, so both +fork tag conventions produce the same v-less version literals. """ from __future__ import annotations @@ -37,10 +40,13 @@ import re from pathlib import Path -# Canonical tag/version shape (SSOT). tag_format = "{version}" means the git tag -# IS the version, so a leading `v` is rejected. Mirrors the real tag set on 1.7, -# which is PEP 440 (`bN`/`aN` betas, four-segment `.N`) -- deliberately broader -# than SemVer so hand-picked maintenance tags stamp cleanly. Consequence: +# Canonical VERSION shape (SSOT). This matches a *version* (what goes into the +# code literals), which never carries a `v`. A `v`-prefixed git tag (BSkando +# `vX.Y.Z`) is normalised by release-stamp.yml -- it strips the optional leading +# `v` before validating/stamping -- so this regex stays strictly v-less. Mirrors +# the real tag set on 1.7, which is PEP 440 (`bN`/`aN` betas, four-segment `.N`) +# -- deliberately broader than SemVer so hand-picked maintenance tags stamp +# cleanly. Consequence: # semantic-release (SemVer-only) cannot compute a trustworthy *proposal* for such # lines, so release.yml (Workflow A) opens an empty hand-tag draft there instead # of proposing a stale number; the stamp path here still honours the exact chosen diff --git a/tests/test_stamp_version.py b/tests/test_stamp_version.py index 43dfbfcfa..307f7f033 100644 --- a/tests/test_stamp_version.py +++ b/tests/test_stamp_version.py @@ -55,7 +55,8 @@ ] INVALID_TAGS = [ - "v1.7.14", # leading v prefix violates tag_format = "{version}" + "v1.7.14", # a v-prefix is not a VERSION; release-stamp.yml strips it off + # the TAG first (see test_tag_to_version_v_strip_semantics) "1.7", # too few segments "1.7.14-rc1", # SemVer-style prerelease, not this scheme "1.7.14rc1", # rc is not in the [ab] prerelease set @@ -185,30 +186,102 @@ def test_workflow_guard_regex_matches_version_re() -> None: def test_real_tip_gate_blanks_foreign_tag_keeps_pep440() -> None: """release.yml REAL_TIP gate: foreign tips blanked, PEP 440 tips kept. - The gate re-uses VERSION_RE (the SSOT) to decide whether the topological - line tip is a valid version. A foreign, non-version tag (Workflow-A hand-tag - placeholder, ``nightly``, ``v2``) must be blanked so it cannot spuriously - route PSR to the hand-tag draft; a genuine PEP 440 beta/four-segment tag must - be kept so a real stale-base mismatch still fires ``MODE=hand``. This mirrors - the shell condition + The gate first strips an optional leading ``v`` (BSkando tags ``vX.Y.Z``), + then re-uses VERSION_RE (the SSOT) to decide whether the topological line tip + is a valid version. A foreign, non-version tag (Workflow-A hand-tag + placeholder, ``nightly``, ``v2`` -> ``2`` after the strip) must be blanked so + it cannot spuriously route PSR to the hand-tag draft; a genuine PEP 440 + beta/four-segment tag -- and a genuine ``v``-prefixed release tag -- must be + kept so a real stale-base mismatch still fires ``MODE=hand``. This mirrors the + shell steps + ``REAL_TIP="${REAL_TIP#v}"`` then ``[ -n "$REAL_TIP" ] && ! printf '%s' "$REAL_TIP" | grep -qE ''``. - Mutation cross-check (re-runnable): replace ``gate``'s body with a bare - ``return real_tip`` (no gating) and the three foreign-tag assertions turn red. + Mutation cross-check (re-runnable): (1) replace ``gate``'s tail with a bare + ``return stripped`` (no gating) and the three foreign-tag assertions turn red; + (2) drop the ``#v`` strip line and ``gate("v1.7.14")`` turns red. """ def gate(real_tip: str) -> str: - # Faithful Python mirror of the release.yml shell gate. - return real_tip if VERSION_RE.match(real_tip) else "" + # Faithful Python mirror of the release.yml shell gate, incl. the + # ``REAL_TIP="${REAL_TIP#v}"`` strip that precedes the VERSION_RE check. + stripped = real_tip[1:] if real_tip.startswith("v") else real_tip + return stripped if VERSION_RE.match(stripped) else "" # foreign / non-version tips are blanked -> no spurious MODE=hand assert gate("set-release-tag") == "" assert gate("nightly") == "" - assert gate("v2") == "" + assert gate("v2") == "" # v-strip -> "2", still not a full version # genuine PEP 440 line tips are kept -> MODE=hand still fires on a mismatch assert gate("1.7.14b1") == "1.7.14b1" assert gate("1.7.14.0") == "1.7.14.0" assert gate("1.7.13") == "1.7.13" + # a genuine v-prefixed release tag (BSkando) is kept as its v-less version + assert gate("v1.7.14") == "1.7.14" + + # Lock the strip into release.yml itself: the mirror above only models the + # intended behaviour, so without this the strip could be dropped from the + # workflow with no red test. + wf = (_REPO_ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + assert 'REAL_TIP="${REAL_TIP#v}"' in wf, ( + "release.yml must strip an optional leading v from REAL_TIP" + ) + + +# --- v-prefix tag -> v-less version mapping (both fork conventions) --------- + + +def test_release_stamp_strips_v_for_version_keeps_tag_for_release() -> None: + """release-stamp.yml maps a (possibly v-prefixed) TAG to a v-less VERSION. + + The version stamped into the code literals is the v-less value + (``VERSION="${TAG_NAME#v}"``), while git/release object addressing keeps the + full published tag (checkout ref, ``rev-list refs/tags``, ``gh release + upload``). Guards against a mutation that strips the wrong variable, which the + byte-parity test above cannot catch (it only checks the regex pattern). + """ + wf = (_REPO_ROOT / ".github/workflows/release-stamp.yml").read_text( + encoding="utf-8" + ) + # The version passed to the stamp script is the v-stripped value. + assert 'VERSION="${TAG_NAME#v}"' in wf, "must derive a v-less VERSION" + assert '--version "$VERSION"' in wf, "stamp must use the v-less VERSION" + # The guard validates the stripped VERSION, not the raw TAG_NAME. + assert "printf '%s' \"$VERSION\" | grep -qE" in wf, ( + "guard must validate the stripped VERSION" + ) + # Git/release object addressing keeps the full published tag (with any `v`). + assert "ref: ${{ github.event.release.tag_name }}" in wf + assert 'git rev-list -n1 "refs/tags/${TAG_NAME}"' in wf + assert 'gh release upload "$TAG_NAME"' in wf, ( + "release upload must address the real tag, not the stripped version " + "(locked by test_hacs_validation.py)" + ) + + +@pytest.mark.parametrize( + ("tag", "version"), + [ + ("v1.7.14", "1.7.14"), # BSkando v-prefix -> v-less version + ("1.7.14", "1.7.14"), # jleinenbach no-prefix -> unchanged (no-op) + ("v1.7.14b1", "1.7.14b1"), # v-prefix beta + ("1.7.13.1", "1.7.13.1"), # four-segment, no prefix + ("vv1", "v1"), # POSIX ${VAR#v} strips exactly ONE leading v + ], +) +def test_tag_to_version_v_strip_semantics(tag: str, version: str) -> None: + """POSIX ``${TAG_NAME#v}`` strips at most one leading ``v``. + + ``vv1 -> v1`` (not ``1``) proves a single-strip, so the guard would still + reject a malformed double-v tag. A real ``v``-prefixed tag maps to a version + that VERSION_RE accepts; the raw ``v``-tag itself does not. + """ + stripped = tag[1:] if tag.startswith("v") else tag + assert stripped == version + if version in {"1.7.14", "1.7.14b1", "1.7.13.1"}: + assert VERSION_RE.match(stripped), f"{stripped!r} must be a valid version" + if tag.startswith("v"): + assert not VERSION_RE.match(tag), f"raw tag {tag!r} must not match" # --- Tag-vs-branch: checkout must pin the published tag --------------------- From 7114812858f30d99f1dac2fd90fc9bcc76eb354a Mon Sep 17 00:00:00 2001 From: Jens Leinenbach <1786119+jleinenbach@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:59:24 +0200 Subject: [PATCH 3/4] ci: tolerate transient HACS validation regression (hacs/integration#5252) (#1206) * ci: tolerate transient HACS validation regression (hacs/integration#5252) The HACS validation job intermittently fails on hacsjson and integration_manifest with "invalid 'hacs.json' file" / "Got None" while the files are byte-identical to a green run and the action image digest is unchanged. This is the known upstream regression hacs/integration#5252 (the action fetches the raw files as None from the GitHub contents API). Run HACS first with continue-on-error; only on failure re-run it with exactly those two transient-prone validators isolated. If everything else passes, the failure was the known transient signature and is tolerated (job green + warning); any other failing check still exits non-zero. The isolation is not permanent (fallback runs only on failure). The real validity of both files stays enforced independently: manifest.json by the hassfest job (needs: hassfest) and hacs.json by the test job. ci-success and release.yml are unchanged. Co-Authored-By: Claude Opus 4.8 * test(ci): harden HACS transient-guard test via YAML parsing Address the terminal diff-review follow-ups (P2/P3): replace the whitespace-fragile string search with structural YAML parsing of the hacs job steps, and assert the continue-on-error placement explicitly (primary + fallback have it, the classifier does not) so the guard cannot regress into a blanket-ignore or a non-blocking classifier. Co-Authored-By: Claude Opus 4.8 * test(hacs): validate hacs.json field types so transient guard cannot mask real schema errors The ci.yml transient-HACS guard classifies a run 'transient' when only hacsjson + integration_manifest fail, relying on the local test job to catch a genuine hacs.json schema error. test_hacs_metadata_matches_manifest only checked allowed keys, not their types, so a non-boolean zip_release/content_in_root could slip through green (Codex #1206 P2). Add explicit field-type validation covering the exact class HACS's hacsjson validator rejects; drop the duplicate path header. --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 47 +++++++++++++++++- tests/test_hacs_validation.py | 94 ++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3294db0cc..e4d01998b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,11 +131,52 @@ jobs: - name: Run HACS validation if: steps.brands_probe.outputs.available == 'true' id: hacs + continue-on-error: true uses: hacs/action@main with: category: integration ignore: topics issues + # Transient HACS regression guard (hacs/integration#5252): the action + # intermittently fetches the raw hacs.json / manifest.json as None from + # the GitHub contents API and reports "invalid 'hacs.json' file" and + # "integration_manifest ... Got None" on byte-identical, valid files. + # If the first run fails, re-run with exactly those two transient-prone + # validators isolated. If everything else passes, the failure was the + # known transient signature and is tolerated (job stays green with a + # warning). The isolation is NOT permanent: it only runs on failure, so a + # healthy HACS still exercises all checks. The real validity of both files + # is still enforced independently at the ci-success level: manifest.json by + # the hassfest job (this job needs: hassfest) and hacs.json by the test job + # (tests/test_hacs_validation.py). Any other failing check keeps this red. + - name: Re-run HACS validation (isolate transient checks) + if: steps.brands_probe.outputs.available == 'true' && steps.hacs.outcome == 'failure' + id: hacs_surgical + continue-on-error: true + uses: hacs/action@main + with: + category: integration + ignore: topics issues hacsjson integration_manifest + + - name: Classify HACS result + if: steps.brands_probe.outputs.available == 'true' + id: hacs_verdict + env: + FIRST: ${{ steps.hacs.outcome }} + RETRY: ${{ steps.hacs_surgical.outcome }} + run: | + set -u + if [ "$FIRST" = "success" ]; then + echo "status=passed" >> "$GITHUB_OUTPUT" + elif [ "$RETRY" = "success" ]; then + echo "::warning ::HACS validation failed only on the transient-prone checks (hacsjson, integration_manifest) and passed once they were isolated; treating as the known transient upstream regression (hacs/integration#5252). File validity is still enforced by the hassfest and test jobs." + echo "status=transient" >> "$GITHUB_OUTPUT" + else + echo "::error ::HACS validation failed on a check beyond the known transient signature; treating as a real defect." + echo "status=failed" >> "$GITHUB_OUTPUT" + exit 1 + fi + - name: Write HACS summary if: always() run: | @@ -144,10 +185,14 @@ jobs: echo "" if [ "${{ steps.brands_probe.outputs.available }}" != "true" ]; then echo "⚠️ **Skipped** - brands.home-assistant.io unavailable" - elif [ "${{ steps.hacs.outcome }}" = "success" ]; then + elif [ "${{ steps.hacs_verdict.outputs.status }}" = "passed" ]; then echo "✅ **HACS validation passed**" echo "" echo "The integration meets HACS requirements." + elif [ "${{ steps.hacs_verdict.outputs.status }}" = "transient" ]; then + echo "⚠️ **HACS validation passed after isolating transient checks** - tolerated upstream regression (hacs/integration#5252)." + echo "" + echo "Only hacsjson/integration_manifest failed; file validity is enforced by the hassfest and test jobs." else echo "❌ **HACS validation failed**" echo "" diff --git a/tests/test_hacs_validation.py b/tests/test_hacs_validation.py index a6a4bdf2c..241ef2f15 100644 --- a/tests/test_hacs_validation.py +++ b/tests/test_hacs_validation.py @@ -1,5 +1,4 @@ # tests/test_hacs_validation.py -# tests/test_hacs_validation.py """Validate HACS metadata alignment and guard against unsupported characters.""" from __future__ import annotations @@ -9,6 +8,7 @@ from pathlib import Path import pytest +import yaml from custom_components.googlefindmy.const import INTEGRATION_VERSION @@ -52,6 +52,41 @@ def test_hacs_metadata_matches_manifest( assert "homeassistant" not in manifest +def test_hacs_metadata_field_types(hacs_metadata: dict[str, object]) -> None: + """hacs.json fields must carry the types HACS's ``hacsjson`` validator requires. + + The ci.yml transient guard (see test_ci_tolerates_transient_hacs_regression) + may classify a HACS run ``transient`` when only ``hacsjson`` + + ``integration_manifest`` fail, on the premise that a *genuine* hacs.json schema + error is caught locally instead. That premise only holds if this job rejects the + same class HACS would: HACS documents ``content_in_root``/``zip_release`` and the + other switches as booleans, so a non-boolean value (e.g. the string ``"true"``) + fails HACS ``hacsjson`` but would otherwise slip through the fallback green. + ``test_hacs_metadata_matches_manifest`` only checks the allowed *keys*, not their + types; validate the types here so the fallback tolerance stays safe. + """ + + # ``isinstance(5, bool)`` is False and ``isinstance("true", bool)`` is False, so + # this rejects exactly the non-boolean values HACS's hacsjson validator rejects. + for field in ( + "content_in_root", + "zip_release", + "render_readme", + "hide_default_branch", + ): + if field in hacs_metadata: + assert isinstance(hacs_metadata[field], bool), ( + f"hacs.json '{field}' must be a boolean; HACS rejects other types " + f"(got {type(hacs_metadata[field]).__name__})" + ) + for field in ("name", "filename", "homeassistant"): + if field in hacs_metadata: + assert isinstance(hacs_metadata[field], str), ( + f"hacs.json '{field}' must be a string " + f"(got {type(hacs_metadata[field]).__name__})" + ) + + def test_no_micro_sign_in_integration_files( integration_python_files: list[Path], integration_root: Path ) -> None: @@ -112,3 +147,60 @@ def test_release_zip_places_manifest_at_root() -> None: assert f'gh release upload "$TAG_NAME" {filename} --clobber' in workflow, ( f"workflow must upload the declared hacs.json asset name {filename!r}" ) + + +def test_ci_tolerates_transient_hacs_regression() -> None: + """ci.yml must guard the HACS job against the transient hacs/integration#5252 + regression without permanently disabling the two affected validators. + + The upstream action intermittently fetches the raw hacs.json / manifest.json + as None and reports them invalid on byte-identical, valid files. The guard + runs HACS first (continue-on-error), and only on failure re-runs it with + exactly ``hacsjson`` and ``integration_manifest`` isolated. If everything + else passes the failure was the known transient signature (tolerated); a + failure of any other check still exits non-zero (real defect). The isolation + must NOT leak into the primary run, otherwise those two checks would be off + permanently. This guard locks the shape so the resilience cannot silently + regress into either a blanket-ignore or a hard block. + """ + + ci = yaml.safe_load(Path(".github/workflows/ci.yml").read_text(encoding="utf-8")) + steps = ci["jobs"]["hacs"]["steps"] + by_id = {step["id"]: step for step in steps if "id" in step} + + primary = by_id["hacs"] + surgical = by_id["hacs_surgical"] + verdict = by_id["hacs_verdict"] + + # Primary run tolerates failure (so the classifier can decide) and keeps the + # original, narrower ignore list: the isolation must not be permanent. + assert primary.get("continue-on-error") is True, ( + "primary HACS run must set continue-on-error so the guard can classify" + ) + assert primary["with"]["ignore"].split() == ["topics", "issues"], ( + "primary HACS run must keep the narrow ignore (isolation not permanent)" + ) + + # Fallback isolates exactly the two transient-prone validators and only runs + # when the primary run failed (never when HACS is healthy). + assert surgical.get("continue-on-error") is True, ( + "fallback HACS run must set continue-on-error" + ) + assert set(surgical["with"]["ignore"].split()) == { + "topics", + "issues", + "hacsjson", + "integration_manifest", + }, "fallback HACS run must isolate exactly hacsjson + integration_manifest" + assert "steps.hacs.outcome == 'failure'" in surgical["if"], ( + "fallback HACS run must be gated on the primary run failing" + ) + + # The classifier must be able to fail the job on a real defect: it must NOT + # be continue-on-error, and must exit non-zero outside the tolerated paths. + assert not verdict.get("continue-on-error", False), ( + "classifier must not be continue-on-error, or a real defect passes silently" + ) + assert "exit 1" in verdict["run"], ( + "classifier must exit non-zero when the failure is not the transient one" + ) From a58e74b578e53a4fc67ea28d9f11006b732008eb Mon Sep 17 00:00:00 2001 From: Jens Leinenbach <1786119+jleinenbach@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:35:26 +0200 Subject: [PATCH 4/4] test(hacs): reproduce ignored HACS validators locally so transient guard cannot mask real regressions (#1207) The ci.yml transient guard (#1206) ignores hacsjson + integration_manifest in its fallback and tolerates the job as transient when the remaining checks pass. test_hacs_metadata_field_types only reproduced hacs.json field *types*, so a *missing* required key (which HACS also rejects) could slip through green. Reproduce both ignored validators locally in the test job (the ci-success gate): - test_hacs_metadata_requires_name: hacs.json must declare a non-empty name. - test_manifest_satisfies_hacs_integration_contract: manifest.json must carry the six keys HACS's integration_manifest validator requires (domain, documentation, issue_tracker, name, version as non-empty strings; codeowners as a non-empty list of strings). Source: hacs.xyz/docs/publish/integration. Closes the N2 residual of PLAN_GFMY_HACS_TRANSIENT_GUARD (Codex #206 P2). --- tests/test_hacs_validation.py | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_hacs_validation.py b/tests/test_hacs_validation.py index 241ef2f15..11d56c26c 100644 --- a/tests/test_hacs_validation.py +++ b/tests/test_hacs_validation.py @@ -87,6 +87,55 @@ def test_hacs_metadata_field_types(hacs_metadata: dict[str, object]) -> None: ) +def test_hacs_metadata_requires_name(hacs_metadata: dict[str, object]) -> None: + """hacs.json must declare a non-empty ``name`` (HACS's ``hacsjson`` validator). + + The ci.yml transient guard (see test_ci_tolerates_transient_hacs_regression) + tolerates a HACS run when only ``hacsjson`` + ``integration_manifest`` fail, on the + premise that a *genuine* metadata regression is caught locally instead. + ``test_hacs_metadata_field_types`` covers a wrong field *type*; this covers a + *missing* required key -- which HACS also rejects, but the fallback would otherwise + wave through green. Source: hacs.xyz/docs/publish/integration. + """ + + name = hacs_metadata.get("name") + assert isinstance(name, str) and name.strip(), ( + "hacs.json must declare a non-empty 'name' string; HACS's hacsjson validator " + "requires it" + ) + + +def test_manifest_satisfies_hacs_integration_contract( + manifest: dict[str, object], +) -> None: + """manifest.json must carry the keys HACS's ``integration_manifest`` validator needs. + + HACS requires an integration manifest to declare ``domain``, ``documentation``, + ``issue_tracker``, ``codeowners``, ``name`` and ``version`` + (hacs.xyz/docs/publish/integration). The ci.yml transient guard ignores + ``integration_manifest`` in its fallback and marks the job ``transient`` when the + remaining checks pass; that is only safe if a *genuine* manifest regression is + caught locally. hassfest overlaps but does not mirror HACS's exact set, so + reproduce the HACS contract here: a missing or wrongly-typed required key fails + this job deterministically, independent of the HACS tolerance. + """ + + for field in ("domain", "documentation", "issue_tracker", "name", "version"): + value = manifest.get(field) + assert isinstance(value, str) and value.strip(), ( + f"manifest.json must declare a non-empty '{field}' string " + "(HACS integration_manifest requirement)" + ) + codeowners = manifest.get("codeowners") + assert isinstance(codeowners, list) and codeowners, ( + "manifest.json 'codeowners' must be a non-empty list " + "(HACS integration_manifest requirement)" + ) + assert all(isinstance(owner, str) and owner.strip() for owner in codeowners), ( + "manifest.json 'codeowners' entries must all be non-empty strings" + ) + + def test_no_micro_sign_in_integration_files( integration_python_files: list[Path], integration_root: Path ) -> None: