Stabilize Home Assistant contract CI

This commit is contained in:
2026-09-02 07:48:48 +10:00
parent 56d74df55a
commit 7f28604887
4 changed files with 88 additions and 81 deletions
+8 -1
View File
@@ -14,9 +14,16 @@ jobs:
strategy:
fail-fast: false
matrix:
home_assistant: ["2024.11.3", stable, dev]
include:
- home_assistant: "2024.11.3"
expect_disabled_cleanup: "0"
- home_assistant: stable
expect_disabled_cleanup: "0"
- home_assistant: dev
expect_disabled_cleanup: "1"
env:
HA_VERSION: ${{ matrix.home_assistant }}
CONTRACT_EXPECT_DISABLED_CLEANUP: ${{ matrix.expect_disabled_cleanup }}
COMPOSE_FILE: tests/ha-contract/compose.yaml
steps:
- uses: actions/checkout@v4
-27
View File
@@ -9,34 +9,7 @@ permissions:
contents: write
jobs:
ha-contract:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
home_assistant: ["2024.11.3", stable, dev]
env:
HA_VERSION: ${{ matrix.home_assistant }}
COMPOSE_FILE: tests/ha-contract/compose.yaml
steps:
- uses: actions/checkout@v4
- name: Start broker and Home Assistant
run: docker compose up -d mqtt homeassistant
- name: Check single-to-device migration
run: docker compose run --rm tests
- name: Restart Home Assistant from retained data
run: docker compose restart homeassistant
- name: Check retained discovery after restart
run: docker compose run --rm -e CONTRACT_MODE=retained-restart tests
- name: Collect Home Assistant logs on failure
if: failure()
run: docker compose logs --no-color homeassistant mqtt
- name: Remove contract volumes
if: always()
run: docker compose down -v
publish:
needs: ha-contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+1
View File
@@ -29,6 +29,7 @@ services:
MQTT_HOST: mqtt
MQTT_PORT: "1883"
CONTRACT_MODE: ${CONTRACT_MODE:-migration}
CONTRACT_EXPECT_DISABLED_CLEANUP: ${CONTRACT_EXPECT_DISABLED_CLEANUP:-0}
volumes:
- contract_state:/state
depends_on:
+79 -53
View File
@@ -24,6 +24,8 @@ MODE = os.environ.get("CONTRACT_MODE", "migration")
STATE = pathlib.Path("/state")
TOKEN_FILE = STATE / "ha-token"
DEVICE_ID = "contract_device"
EDGE_DEVICE_ID = "contract_edge_device"
EXPECT_DISABLED_CLEANUP = os.environ.get("CONTRACT_EXPECT_DISABLED_CLEANUP") == "1"
class ContractFailure(RuntimeError):
@@ -223,36 +225,36 @@ class RetainedPublisher:
return value
def legacy_topic(object_id):
return f"homeassistant/sensor/{DEVICE_ID}/{object_id}/config"
def legacy_topic(object_id, device_id=DEVICE_ID):
return f"homeassistant/sensor/{device_id}/{object_id}/config"
def device_topic():
return f"homeassistant/device/{DEVICE_ID}/config"
def device_topic(device_id=DEVICE_ID):
return f"homeassistant/device/{device_id}/config"
def component(object_id, unique_id=None, **extra):
def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
payload = {
"p": "sensor",
"name": object_id.replace("_", " ").title(),
"uniq_id": unique_id or f"{DEVICE_ID}_{object_id}",
"uniq_id": unique_id or f"{device_id}_{object_id}",
"stat_t": f"contract/{object_id}/state",
}
payload.update(extra)
return payload
def device_payload(components):
def device_payload(components, device_id=DEVICE_ID):
return {
"dev": {"ids": [DEVICE_ID], "name": "ArduinoHA contract device"},
"o": {"name": "ArduinoHA", "sw": "3.0.2"},
"dev": {"ids": [device_id], "name": "ArduinoHA contract device"},
"o": {"name": "ArduinoHA", "sw": "contract"},
"cmps": components,
}
def legacy_payload(object_id, unique_id=None, **extra):
def legacy_payload(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
payload = component(object_id, unique_id, **extra)
payload["dev"] = {"ids": [DEVICE_ID], "name": "ArduinoHA contract device"}
payload["dev"] = {"ids": [device_id], "name": "ArduinoHA contract device"}
payload.pop("p")
return payload
@@ -326,65 +328,89 @@ def migration_contract():
if remigrated["id"] != original_id:
fail("repeat single-to-device migration changed the entity registry ID")
retained = json.loads(publisher.retained_payload(device_topic()))
if "temperature" not in retained.get("cmps", {}):
fail("migration did not retain the primary device discovery payload")
# Direct publication is deliberately not a migration protocol. It must not
# create a second registry entry for the same stable unique ID.
direct_unique = f"{DEVICE_ID}_direct"
publisher.publish(legacy_topic("direct"), json.dumps(legacy_payload("direct", direct_unique)))
# create a second registry entry for the same stable unique ID. Keep it
# on a separate device topic so it cannot invalidate the primary
# retained-payload/restart contract.
direct_unique = f"{EDGE_DEVICE_ID}_direct"
publisher.publish(
legacy_topic("direct", EDGE_DEVICE_ID),
json.dumps(legacy_payload("direct", direct_unique, device_id=EDGE_DEVICE_ID)),
)
direct_entry = wait_for_entry(ws, direct_unique)
publisher.publish(device_topic(), json.dumps(device_payload({"direct": component("direct", direct_unique)})))
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload(
{"direct": component("direct", direct_unique)}, device_id=EDGE_DEVICE_ID
)),
)
time.sleep(2)
entries = [entry for entry in ws.registry_entries() if entry.get("unique_id") == direct_unique]
if len(entries) != 1 or entries[0]["id"] != direct_entry["id"]:
fail("direct device discovery publish created a duplicate registry entity")
# Device-mode removal is two root updates: platform tombstone then omission.
removable_unique = f"{DEVICE_ID}_removable"
# Device-mode removal is two root updates: platform tombstone then
# omission. This uses the edge fixture so retained-restart behavior is
# independently asserted on the migrated primary fixture.
removable_unique = f"{EDGE_DEVICE_ID}_removable"
publisher.publish(
device_topic(),
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor"),
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"removable": component("removable", removable_unique),
})),
}, device_id=EDGE_DEVICE_ID)),
)
wait_for_entry(ws, removable_unique)
publisher.publish(
device_topic(),
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor"),
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"removable": {"p": "sensor"},
})),
}, device_id=EDGE_DEVICE_ID)),
)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
)
publisher.publish(device_topic(), json.dumps(device_payload({"anchor": component("anchor")})))
# HA 2026.5 fixed cleanup for discovered entities that start disabled.
# Exercise the same device-component tombstone/omission sequence so a
# future regression is caught by the stable/dev contract matrix.
disabled_unique = f"{DEVICE_ID}_disabled"
publisher.publish(
device_topic(),
json.dumps(device_payload({
"anchor": component("anchor"),
"disabled": component(
"disabled", disabled_unique, enabled_by_default=False
),
})),
)
disabled_entry = wait_for_entry(ws, disabled_unique)
if disabled_entry.get("disabled_by") != "integration":
fail(f"expected initially disabled entity to be integration-disabled: {disabled_entry}")
publisher.publish(
device_topic(),
json.dumps(device_payload({
"anchor": component("anchor"),
"disabled": {"p": "sensor"},
})),
)
publisher.publish(device_topic(), json.dumps(device_payload({"anchor": component("anchor")})))
wait_until(
"disabled device component cleanup",
lambda: find_unique(ws.registry_entries(), disabled_unique) is None,
timeout=60,
)
# Older Home Assistant versions do not clean initially disabled device
# components. Keep that capability assertion in the development lane,
# where it detects regressions without making the supported baseline
# falsely fail.
if EXPECT_DISABLED_CLEANUP:
disabled_unique = f"{EDGE_DEVICE_ID}_disabled"
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"disabled": component(
"disabled", disabled_unique, enabled_by_default=False
),
}, device_id=EDGE_DEVICE_ID)),
)
disabled_entry = wait_for_entry(ws, disabled_unique)
if disabled_entry.get("disabled_by") != "integration":
fail(f"expected initially disabled entity to be integration-disabled: {disabled_entry}")
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"disabled": {"p": "sensor"},
}, device_id=EDGE_DEVICE_ID)),
)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
)
wait_until(
"disabled device component cleanup",
lambda: find_unique(ws.registry_entries(), disabled_unique) is None,
timeout=60,
)
# Invalid retained discovery JSON never creates a registry entry. Escaped
# strings are covered by the firmware-native serializer tests.