Skip to content

Commit 54f4d5a

Browse files
fix: promote-flow dev version rebase and e2e entry-setup gate (#2040)
* fix: base reset dev version on the next stable patch reset_version computed new_base = max(stable_base, dev_base), so after every normal promote the dev base equaled stable's — and PEP 440 sorts X.Y.Z.devN BEHIND X.Y.Z, so the dev channel advertised a version reading older than the stable carrying the same code. AGENTS.md has always documented the next-stable-patch rule; nothing enforced it, so the drift was silent (Patch76's review finding on #2037). The base is now max(next_patch(stable), dev_base) with the counter restarting on a base jump, and the test suite pins the sorts-ahead-of-stable invariant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * fix: gate e2e bring-up on all config entries finishing setup The unloaded-entry diagnostic named the recurring test_include_options_flag failure: the seeded HACS entry is still not_loaded when CoreState.RUNNING trips (RUNNING only means setup was dispatched), so the OptionsFlow probe sees all-empty options while a sibling test probing the same entry seconds later passes. Add the follow-up gate the drift warning has been calling justified: a bounded wait for entries_loaded == entries_total, entered only when the trip-time snapshot caught a straggler, logging loudly on timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * feat: auto-dispatch the dev reset when a promote PR merges The promotion flow ended at the draft promote PR; rebasing the dev version line onto the new stable was an undocumented manual dispatch. A post-promote workflow now fires Reset Dev from Stable on promote-PR merge (human-merged, so the event triggers; workflow_dispatch is GITHUB_TOKEN-permitted), and the promotion doc gains the missing step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * fix: rebase the dev version line inside the promote PR itself Replace the just-added post-promote dispatcher: instead of spawning a reset PR after every promote merge, the promote transform now rewrites the dev flavor's two version files (rebase_dev_version — next stable patch + .dev1) in the same PR, the workflow stages them, and the doc's step 6 describes it. Reset Dev from Stable remains a manual tool for the stable-hotfix backport case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * chore(addon-dev): rebase dev version line onto stable 2.1.0 (2.1.1.dev1) The 2.1.0 promotion (#2037) predates the in-PR rebase this branch adds, so dev still advertised 2.0.5.dev3 — sorting behind the stable carrying the same code. One-time application of rebase_dev_version; future promotions carry this inside the promote PR itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * fix: enter the entries_loaded gate when the trip snapshot is unavailable A failed trip-time snapshot (snapshot_ok=False) returns an empty unloaded string, which skipped the follow-up gate while entries could still be loading — preserving the race behind a transient HTTP/JSON hiccup. The skip now requires positive all-loaded confirmation; the gate's loop polls through snapshot failures and stays bounded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f2740c2 commit 54f4d5a

8 files changed

Lines changed: 190 additions & 17 deletions

File tree

.github/workflows/webhook-proxy-promote.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ jobs:
5959
git config user.name "github-actions[bot]"
6060
git config user.email "github-actions[bot]@users.noreply.github.com"
6161
git checkout -b "$BRANCH"
62-
git add homeassistant-addon-webhook-proxy/
62+
git add homeassistant-addon-webhook-proxy/ \
63+
homeassistant-addon-webhook-proxy-dev/config.yaml \
64+
homeassistant-addon-webhook-proxy-dev/mcp_proxy_dev/manifest.json
6365
git commit -m "chore(addon): promote webhook-proxy dev -> stable $NEW_VERSION"
6466
git push -u origin "$BRANCH"
6567
cat > "$RUNNER_TEMP/pr-body.md" <<EOF
@@ -68,6 +70,9 @@ jobs:
6870
It copied the dev add-on onto the stable add-on, reverse-renamed the
6971
\`mcp_proxy_dev\` identity back to \`mcp_proxy\`, restored the stable
7072
mutual-exclusion constants, and set the stable version to \`$NEW_VERSION\`.
73+
It also rebased the dev flavor's version line onto the new stable
74+
(next stable patch + \`.dev1\`), so no follow-up reset PR is needed
75+
for the version alone.
7176
7277
The transform syncs code + identity + version + \`DOCS.md\` (component
7378
token rename plus the flavor-banner swap; canonical banners live in

homeassistant-addon-webhook-proxy-dev/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ history from before the fork.
99
-->
1010

1111

12+
## v2.1.1.dev1 (2026-07-26)
13+
14+
Version line rebased onto the 2.1.0 stable base (no code changes — dev and
15+
stable are identical as of the 2.1.0 promotion). Future promotions carry this
16+
rebase inside the promote PR itself (`rebase_dev_version`).
17+
18+
1219
## v2.0.5.dev3 (2026-07-26)
1320

1421
### Bug Fixes

homeassistant-addon-webhook-proxy-dev/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: "Nabu Casa - Webhook Proxy for HA MCP (Dev)"
22
description: "DEV CHANNEL (unstable) — remote access proxy via Nabu Casa or any reverse proxy. Cannot run alongside the stable Webhook Proxy add-on."
3-
version: "2.0.5.dev3"
3+
version: "2.1.1.dev1"
44
slug: "ha_mcp_webhook_proxy_dev"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
stage: experimental

homeassistant-addon-webhook-proxy-dev/mcp_proxy_dev/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"dependencies": ["webhook"],
88
"documentation": "https://github.com/homeassistant-ai/ha-mcp",
99
"iot_class": "local_push",
10-
"version": "2.0.5.dev3"
10+
"version": "2.1.1.dev1"
1111
}

homeassistant-addon-webhook-proxy/AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ manual fallback):
8585
4. Set `boot: auto`, drop `stage: experimental`, remove the `(Dev)` display-name
8686
suffixes.
8787
5. Bump stable's own `config.yaml` + `manifest.json` version (its independent line).
88+
6. Rebase the dev flavor's version line onto the new stable in the SAME promote PR
89+
(next stable patch + `.dev1`, per the Versioning rule above — the transform's
90+
`rebase_dev_version` does this; only dev `config.yaml` + `manifest.json` change).
91+
Right after a promote the trees are code-identical, so no separate reset PR is
92+
needed; `Reset Dev from Stable` remains a manual tool for the stable-hotfix
93+
backport case.
8894

8995
## Testing
9096
`tests/addon/test_webhook_proxy.py` is parametrized over BOTH flavors — an autouse

scripts/webhook_proxy_sync.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,24 @@ def promote_version(current_stable: str, bump: str) -> str:
156156

157157

158158
def reset_version(current_dev: str, current_stable: str) -> str:
159-
"""Next dev version, guaranteed to strictly increase.
160-
161-
``new_base = max(stable_base, dev_base)`` and the ``devN`` counter always
162-
advances by one. Because the base never decreases and the counter always
163-
rises, the result strictly increases even when stable's base is *behind*
164-
the current dev base (the code goes backward; the version still climbs).
159+
"""Next dev version, strictly increasing AND sorting ahead of stable.
160+
161+
``new_base = max(next_stable_patch, dev_base)``: AGENTS.md's rule is that
162+
the dev base is the NEXT stable patch (stable ``2.0.2`` → ``2.0.3.devN``)
163+
so dev always sorts ahead of the stable it will promote into — under
164+
PEP 440 a ``X.Y.Z.devN`` pre-release sorts BEHIND ``X.Y.Z``, so basing
165+
dev on stable's own base advertised the dev channel as older than the
166+
stable carrying the same code. A dev base already further ahead (an
167+
escalated line in progress) is kept. The ``devN`` counter restarts at 1
168+
on a base jump and advances by one otherwise, so the result strictly
169+
increases either way.
165170
"""
166171
dev_base, dev_n = parse_dev_version(current_dev)
167172
stable_base = parse_stable_version(current_stable)
168-
new_base = max(stable_base, dev_base)
169-
return "{}.{}.{}.dev{}".format(*new_base, dev_n + 1)
173+
next_patch = (stable_base[0], stable_base[1], stable_base[2] + 1)
174+
new_base = max(next_patch, dev_base)
175+
new_n = dev_n + 1 if new_base == dev_base else 1
176+
return "{}.{}.{}.dev{}".format(*new_base, new_n)
170177

171178

172179
# ---------------------------------------------------------------------------
@@ -408,6 +415,31 @@ def _ruff_format(paths: list[Path], root: Path) -> None:
408415
)
409416

410417

418+
def rebase_dev_version(new_stable: str, root: Path = REPO_ROOT) -> str:
419+
"""Rewrite the dev flavor's version files onto ``new_stable``'s line.
420+
421+
Called by the promote transform so the promote PR itself carries the dev
422+
version rebase (next stable patch + ``.dev1`` — see ``reset_version``)
423+
instead of a follow-up reset PR existing only for the version line. Only
424+
the two version-bearing files are touched; the dev code is left alone
425+
(right after a promote it is identical to stable anyway). Reuses the
426+
identity writers, which are idempotent on the dev tree's own identity.
427+
"""
428+
dev_dir = root / DEV.addon_dir
429+
new_dev = reset_version(_read_config_version(dev_dir / "config.yaml"), new_stable)
430+
config_yaml = dev_dir / "config.yaml"
431+
config_yaml.write_text(
432+
transform_config_yaml(config_yaml.read_text(encoding="utf-8"), DEV, new_dev),
433+
encoding="utf-8",
434+
)
435+
manifest = dev_dir / DEV.component / "manifest.json"
436+
manifest.write_text(
437+
apply_manifest(manifest.read_text(encoding="utf-8"), DEV, new_dev),
438+
encoding="utf-8",
439+
)
440+
return new_dev
441+
442+
411443
def sync(direction: str, bump: str | None = None, root: Path = REPO_ROOT) -> str:
412444
"""Apply the promote/reset transform in place. Returns the new version."""
413445
if direction == "promote":
@@ -508,6 +540,13 @@ def sync(direction: str, bump: str | None = None, root: Path = REPO_ROOT) -> str
508540

509541
# Re-wrap .py lines whose length crossed 88 cols due to the token swap.
510542
_ruff_format([start_py, *sorted(dst_comp.rglob("*.py"))], root)
543+
544+
if direction == "promote":
545+
# The promote PR itself carries the dev version-line rebase — see
546+
# rebase_dev_version. Stdout stays the stable version (the promote
547+
# workflow captures it); the dev version is visible in the diff.
548+
rebase_dev_version(version, root)
549+
511550
return version
512551

513552

tests/src/e2e/conftest.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,8 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config):
415415
f"⚠️ [READINESS_GATE_DRIFT] {len(drift_samples)} core_state sample(s) "
416416
f"with entries_loaded < entries_total — a slow integration "
417417
f"finished async_setup_entry after CoreState.RUNNING; "
418-
f"follow-up gate justified."
418+
f"the entries_loaded follow-up gate absorbed the wait (see its "
419+
f"timing line; a timed_out=True line there means it did not)."
419420
+ (f" Not loaded: {'; '.join(culprits)}" if culprits else "")
420421
)
421422

@@ -1236,6 +1237,66 @@ def _snapshot_config_entries(
12361237
return 0, 0, False, ""
12371238

12381239

1240+
# Bounded budget for the entries_loaded follow-up gate. HACS (the only entry
1241+
# observed lagging — it fetches remote data during setup) typically finishes
1242+
# seconds after CoreState.RUNNING; 60s is generous without stalling a genuinely
1243+
# broken container for long.
1244+
_ENTRIES_LOADED_TIMEOUT = 60
1245+
1246+
1247+
def _wait_for_entries_loaded(
1248+
base_url: str,
1249+
headers: dict[str, str],
1250+
timeout: int = _ENTRIES_LOADED_TIMEOUT,
1251+
) -> None:
1252+
"""Follow-up readiness gate: wait for every config entry to reach ``loaded``.
1253+
1254+
``CoreState.RUNNING`` only means every ``async_setup_entry`` was
1255+
DISPATCHED; a slow one can still be running. The drift telemetry
1256+
repeatedly caught the seeded HACS entry ``not_loaded`` at trip time
1257+
(#2033's arm lanes, #2040's x86 lane), which the OptionsFlow probe tests
1258+
then observe as all-empty options — the #1245 regression signature —
1259+
while a sibling test probing the same entry seconds later passes. This
1260+
gate closes exactly that window.
1261+
1262+
Bounded and non-fatal: on timeout the unloaded entries are logged loudly
1263+
and the session proceeds — a genuinely broken entry should fail its own
1264+
tests with the timing line as the named cause, not abort the whole run.
1265+
"""
1266+
start = time.monotonic()
1267+
while True:
1268+
loaded, total, ok, unloaded = _snapshot_config_entries(base_url, headers)
1269+
elapsed = time.monotonic() - start
1270+
if ok and total > 0 and loaded >= total:
1271+
_log_readiness_timing(
1272+
"entries_loaded",
1273+
elapsed,
1274+
entries_loaded=loaded,
1275+
entries_total=total,
1276+
snapshot_ok=ok,
1277+
)
1278+
return
1279+
if elapsed >= timeout:
1280+
logger.warning(
1281+
"entries_loaded gate timed out after %.0fs: %d/%d loaded%s",
1282+
elapsed,
1283+
loaded,
1284+
total,
1285+
f" (not loaded: {unloaded})" if unloaded else "",
1286+
)
1287+
_log_readiness_timing(
1288+
"entries_loaded",
1289+
elapsed,
1290+
entries_loaded=loaded,
1291+
entries_total=total,
1292+
snapshot_ok=ok,
1293+
timed_out=True,
1294+
**({"unloaded": unloaded} if unloaded else {}),
1295+
)
1296+
return
1297+
time.sleep(1)
1298+
1299+
12391300
def _wait_for_core_state_running(
12401301
base_url: str,
12411302
headers: dict[str, str],
@@ -2384,6 +2445,16 @@ def _wait_for_testcontainer_ready(
23842445
**({"unloaded": entries_unloaded} if entries_unloaded else {}),
23852446
)
23862447

2448+
# Follow-up gate: RUNNING does not imply every async_setup_entry finished
2449+
# (see _wait_for_entries_loaded). Skipped only when the trip-time snapshot
2450+
# POSITIVELY confirmed all-loaded — a failed snapshot (snapshot_ok=False)
2451+
# says nothing about entry state, so it enters the gate too rather than
2452+
# preserving the race behind a transient HTTP/JSON hiccup (Codex review
2453+
# finding on #2040); the gate's own loop keeps polling through snapshot
2454+
# failures and stays bounded by its timeout.
2455+
if entries_unloaded or not snapshot_ok:
2456+
_wait_for_entries_loaded(base_url, headers)
2457+
23872458
_wait_for_testcontainer_sun(base_url, headers, container, SUN_WAIT)
23882459

23892460
# Embedded backend: HA core is up, but the in-process MCP server

tests/src/unit/test_webhook_proxy_sync.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,21 @@ def test_bump_stable_tuple_math():
8686
# ---------------------------------------------------------------------------
8787
# Reset: dev version must ALWAYS strictly increase
8888
# ---------------------------------------------------------------------------
89-
def test_reset_bumps_dev_counter_when_bases_equal():
90-
assert sync.reset_version("1.2.3.dev1", "1.2.3") == "1.2.3.dev2"
89+
def test_reset_moves_to_next_stable_patch_with_fresh_counter():
90+
# AGENTS.md's rule: dev base = the NEXT stable patch, so the dev channel
91+
# sorts AHEAD of the stable it will promote into (PEP 440 puts
92+
# 1.2.3.devN BEHIND 1.2.3). Counter restarts on the base jump.
93+
assert sync.reset_version("1.2.3.dev1", "1.2.3") == "1.2.4.dev1"
9194

9295

93-
def test_reset_takes_stable_base_when_stable_is_ahead():
94-
# stable ahead of dev's base -> base climbs, counter still advances
95-
assert sync.reset_version("1.0.0.dev3", "1.5.0") == "1.5.0.dev4"
96+
def test_reset_takes_next_patch_above_stable_when_stable_is_ahead():
97+
# stable ahead of dev's base -> base climbs past it, counter restarts
98+
assert sync.reset_version("1.0.0.dev3", "1.5.0") == "1.5.1.dev1"
99+
100+
101+
def test_reset_advances_counter_when_dev_base_already_next_patch():
102+
# dev already sits on the next stable patch -> same base, counter advances
103+
assert sync.reset_version("1.2.4.dev2", "1.2.3") == "1.2.4.dev3"
96104

97105

98106
def test_reset_still_increases_when_stable_base_is_behind_dev():
@@ -119,6 +127,43 @@ def test_reset_monotonic_across_a_range_of_pairs():
119127
assert _version_key(new) > _version_key(current_dev), (
120128
f"reset({current_dev}, {current_stable}) = {new} did not increase"
121129
)
130+
# The doc invariant the old max-base rule silently violated: the new
131+
# dev version must sort AHEAD of the stable it will promote into
132+
# (_version_key models PEP 440's devN-before-release ordering).
133+
assert _version_key(new) > _version_key(current_stable), (
134+
f"reset({current_dev}, {current_stable}) = {new} sorts behind stable"
135+
)
136+
137+
138+
def test_rebase_dev_version_writes_both_files_in_place(tmp_path):
139+
"""The promote transform's in-PR dev version rebase: only the two
140+
version-bearing dev files change, onto the next-stable-patch line."""
141+
stable_cfg = (
142+
'name: "x"\ndescription: "y"\nversion: "2.1.0"\n'
143+
'slug: "s"\nurl: "u"\narch:\n - amd64\nboot: auto\n'
144+
)
145+
dev_dir = tmp_path / sync.DEV.addon_dir
146+
(dev_dir / sync.DEV.component).mkdir(parents=True)
147+
(dev_dir / "config.yaml").write_text(
148+
sync.transform_config_yaml(stable_cfg, sync.DEV, "2.0.5.dev3"),
149+
encoding="utf-8",
150+
)
151+
manifest = dev_dir / sync.DEV.component / "manifest.json"
152+
manifest.write_text(
153+
'{\n "domain": "mcp_proxy_dev",\n "name": "n",\n'
154+
' "version": "2.0.5.dev3"\n}\n',
155+
encoding="utf-8",
156+
)
157+
158+
new_dev = sync.rebase_dev_version("2.1.0", root=tmp_path)
159+
160+
assert new_dev == "2.1.1.dev1"
161+
cfg_text = (dev_dir / "config.yaml").read_text(encoding="utf-8")
162+
assert 'version: "2.1.1.dev1"' in cfg_text
163+
# Idempotent identity rewrite: still exactly one stage line, dev-shaped.
164+
assert cfg_text.split("\n").count("stage: experimental") == 1
165+
assert '"version": "2.1.1.dev1"' in manifest.read_text(encoding="utf-8")
166+
assert _version_key(new_dev) > _version_key("2.1.0")
122167

123168

124169
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)