Skip to content

Commit 5b7e215

Browse files
feat: land machine translations post-merge instead of in the PR (#2115)
* feat: land machine translations post-merge instead of in the PR The locale pipeline moves from committing translations onto the PR branch (same-repo PRs only; fork PRs were skipped) to a daily post-merge run that pushes them straight to master with the release App credential. The completeness checks in test_locale_parity.py gate behind LOCALE_COMPLETENESS_CHECKS=1 and run in the workflow as its verification; PR CI keeps only deterministic, engine-free obligations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgWpy7arov56hET1PzoeCS * fix: address review findings — restore two-job token isolation, split verification, close fail-open gate Codex P1s: the push credential moves back to a fresh job consuming a patch artifact through the rename pre-checks and locale allowlist; the component placeholder check returns to PR CI, restricted to keys whose English matches the baseline. Review-agent findings: the staleness check (held gated-stub keys) no longer blocks the push — it reddens the job instead of discarding every translation and re-burning quota daily; both verification steps assert zero junit skips and test_locale_sync_gate_shape.py pins the gate wiring pre-merge; the filled-tool-sections asserts split out of test_shipped_catalog_loads_and_is_registered so a meta-only stub language can merge; wrong-ref dispatches fail loudly; docs now state that hand-translating a changed English string requires a baseline repin in the same PR (AGENTS.md, locales README, settings_ui AGENTS.md, CONTRIBUTING.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgWpy7arov56hET1PzoeCS * fix: harden the push path per bot review round two Codex P1: the push job now refuses any settings.js change outside the generated FEATURE_META block (byte-compare against master with the region stripped) — generate_locales preserves everything outside the markers, so the whole-file allowlist entry was a smuggling channel for served client-side JS. Codex P2s: the master-write concurrency group moves to the short push job so the translate leg cannot make a hotfix release queue behind it (a locale-sync group prevents self-overlap), and the dispatch guard compares the full ref so a tag named after the default branch cannot pass. CodeRabbit: the guard's ref values reach the shell through env, never expanded (zizmor template-injection); the rebase-retry fetches through the same authenticated URL as the push; the shape test's export assertion narrows to the git-add step; the baseline-drift set is computed once, cached, across the placeholder test's parametrization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgWpy7arov56hET1PzoeCS * fix: drop settings.js from the sync patch and pin gate semantics, per review Patch76's review, both concerns. settings.js leaves the export and the allowlist entirely: its generated FEATURE_META block derives from en.json alone, which the pipeline never touches and PR CI keeps current, so the sync never legitimately stages the file — the marker region byte-compare it replaces guarded only outside the markers while the block itself is executed JavaScript. With no executable file on the patch, the no-code property holds by construction. The shape test now pins semantics instead of tokens: the push predicate is asserted with its negation, the allowlist arms are compared as an exact set, the dispatch guard's actual ref comparison is required, and every junit report written must be consumed. Replayed the review's mutation matrix — inverting the predicate, widening the allowlist, hollowing the guard, and deleting the skip-check call sites each now fail exactly one assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgWpy7arov56hET1PzoeCS * test: pin the catch-all's refusal and the credential-free checkout Patch76's second matrix pass: gutting the catch-all arm to '*) ;;' leaves the allowlist's parsed arm SET unchanged while admitting every path — its exit 1 is the entire allowlist — and nothing read the checkout steps, so removing persist-credentials: false survived too. Both pinned; both mutations now fail exactly one assertion each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgWpy7arov56hET1PzoeCS --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 037b3fe commit 5b7e215

9 files changed

Lines changed: 696 additions & 142 deletions

File tree

.github/workflows/locale-sync.yml

Lines changed: 242 additions & 97 deletions
Large diffs are not rendered by default.

AGENTS.md

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ On merge, `hotfix-release.yml` runs semantic-release, creates GitHub release, sy
336336
| `build-binary.yml` | Release | Linux/macOS/Windows binaries |
337337
| `addon-publish.yml` | Release | HA add-on update |
338338
| `sync-tool-docs.yml` | Push to master (`src/ha_mcp/tools/`, `scripts/extract_tools.py`) | Regenerate `tools.json`, README, DOCS.md |
339-
| `locale-sync.yml` | Same-repo PR touching locale catalogs, tool sources, or the locale scripts | Machine-translate stale/missing strings and commit them to the PR branch |
339+
| `locale-sync.yml` | Daily schedule + manual dispatch | Machine-translate stale/missing strings post-merge and push them straight to master |
340340

341341
**Docker image tags** (`ghcr.io/homeassistant-ai/ha-mcp`): stable releases push `:latest` + `:stable` + semver tags (`release-publish.yml`); dev builds push only `:dev` + `:dev-<sha>` (`publish-dev.yml`) — **never `:latest`**, which is reserved for stable. The HA add-on images live in separate repos (`-addon-{arch}`, `-addon-dev-{arch}`) and are selected by an explicit `version:` pin, not by `:latest`.
342342

@@ -745,10 +745,9 @@ lacks: nothing renders it. `tool_groups` and `tools` may do neither: each locale
745745
must carry exactly the renderable group headings and every tool name, no key
746746
more and none fewer. The check derives the tool set from
747747
the sources (`scripts/extract_tools.py`), not from the committed
748-
`site/src/data/tools.json` that `sync-tool-docs.yml` regenerates only after
749-
merge — so the PR adding a tool goes red, rather than the next PR someone
750-
opens. Separately from those key rules, both authored surfaces cap how much
751-
*text* a catalog
748+
`site/src/data/tools.json` — the check must not depend on a generated
749+
artifact that a separate post-merge workflow keeps current. Separately from
750+
those key rules, both authored surfaces cap how much *text* a catalog
752751
may leave byte-identical to English or omit outright, so a stub cannot ride the
753752
fallbacks: 5% for the settings UI `messages`, its `tools` titles and
754753
descriptions, and each generated add-on projection (per flavor, computed from
@@ -767,8 +766,24 @@ baseline diff (`tests/src/unit/locale_source_baseline.json`), retranslates
767766
the changed or missing keys in every language via the Gemini API
768767
(`GEMINI_API_KEY`; free tier), validates placeholders and markup, regenerates
769768
the derived catalogs, and repins the baseline. The `locale-sync.yml` workflow
770-
runs it automatically on same-repo PRs and commits the result to the PR
771-
branch for review; run it locally for fork PRs or to use a different engine.
769+
runs it AFTER merge, on a daily schedule, and pushes the result straight to
770+
master with the release App credential (the same pattern as the version-bump
771+
bots and `sync-tool-docs.yml`) — so any PR, fork or same-repo, merges
772+
without owing translations, and one sync run picks up everything merged
773+
since the last one. The checks that police translated content (missing or
774+
orphaned keys, staleness against the baseline, cross-surface shared wording,
775+
the untranslated-share ceilings, filled tool sections) are gated behind
776+
`LOCALE_COMPLETENESS_CHECKS=1` and run in that workflow, not in PR CI —
777+
`test_locale_sync_gate_shape.py` pins the wiring. What a PR still owes is
778+
deterministic and engine-free: regenerate the derived catalogs
779+
(`python scripts/generate_locales.py`) when a canonical English string
780+
changes, and placeholder parity on component keys whose English is current.
781+
To choose the wording yourself, translate in your own PR **and run
782+
`python scripts/update_locale_baseline.py` in it** — the repinned baseline
783+
is what tells the next sync your wording already covers the changed English
784+
(hand-edits win); without the repin the sync retranslates the key and
785+
overwrites you. Run `scripts/translate_locales.py` locally instead to
786+
machine-fill in-PR or to use a different engine (it repins for you).
772787
The baseline pins the English each translation was written against, because
773788
key parity cannot see a string whose meaning changed: #1993 flipped a policy
774789
string from ALL-match to ANY-match and left the Chinese text asserting the
@@ -783,8 +798,8 @@ tool shows one instead. Editing that summary moves the English out from under
783798
six catalogs; the pipeline retranslates them. One deliberate exception: a
784799
change to a feature-gated tool's PARSED docstring (its stub unchanged) is
785800
stub-review work, not translation work — the pipeline holds that baseline key
786-
stale, and the red check clears only when a human confirms the stub still
787-
describes the tool and runs `python scripts/update_locale_baseline.py`.
801+
stale, and the locale-sync run stays red until a human confirms the stub
802+
still describes the tool and runs `python scripts/update_locale_baseline.py`.
788803

789804
**Rate limits and outages degrade loudly, never silently.** Engine calls are
790805
paced under the free-tier request rate and retry transient errors (429/5xx,
@@ -793,14 +808,15 @@ and the run continues, and two consecutive dead batches stop the run early
793808
instead of burning the remaining quota. A partial run — a daily-quota hit,
794809
an outage — still commits every finished translation plus
795810
`tests/src/unit/locale_sync_progress.json`, which the next run reads to
796-
resume where it stopped: **re-running the workflow is the entire recovery
797-
procedure.** Only a fully successful run repins the baseline and deletes the
798-
progress file, so CI stays red until every string is translated and nothing
811+
resume where it stopped: **re-running the workflow — or just waiting for the
812+
next day's cron — is the entire recovery procedure.** Only a fully
813+
successful run repins the baseline and deletes the progress file, so the
814+
sync runs stay red until every string is translated and nothing
799815
unvalidated ever ships. **The fallback when the engine is down is a human**:
800-
anyone (the PR author included) can hand-translate the strings the dry-run
816+
anyone can hand-translate the strings the dry-run
801817
lists, run `python scripts/generate_locales.py` and
802-
`python scripts/update_locale_baseline.py`, and push — CI goes green and the
803-
next pipeline run no-ops (it also cleans up any committed progress file).
818+
`python scripts/update_locale_baseline.py`, and open an ordinary PR — the
819+
next sync run no-ops (it also cleans up any committed progress file).
804820
Hand-edits always win; the machine only ever touches strings whose English
805821
changed. The engine itself is one function (`_call_gemini`) with
806822
`GEMINI_API_URL` / `GEMINI_MODEL` / `GEMINI_API_KEY` overrides for any

CONTRIBUTING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ uv run lefthook install --reset-hooks-path
5050
- **Docs**: Update README.md for user-facing changes
5151
- **PRs**: Use the template, ensure tests pass
5252
- **Translations**: A language ships on all four translated surfaces or not at
53-
all. Read `src/ha_mcp/settings_ui/locales/README.md` first — a catalog copied
54-
from `en.json` fails CI
53+
all. Read `src/ha_mcp/settings_ui/locales/README.md` first. Changing an
54+
English string owes no translations — the post-merge locale-sync workflow
55+
machine-fills them daily, and its verification rejects a catalog left
56+
byte-identical to English
5557

5658
## 💤 Abandoned PRs
5759

scripts/translate_locales.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
the run early. Partial runs are resumable — completed work is recorded in
3737
``tests/src/unit/locale_sync_progress.json`` and skipped on the rerun; only a
3838
fully successful run repins the baseline and deletes that record, so the
39-
parity suite stays red until every string is translated.
39+
locale-sync runs stay red until every string is translated.
4040
4141
Usage::
4242
@@ -1002,8 +1002,9 @@ def _repin_baseline(module: Any) -> None:
10021002
so nothing on screen went stale, but someone must confirm the stub still
10031003
describes the tool. Automation must not wave that through — changed
10041004
``" (parsed)"`` keys keep their OLD hash here, leaving
1005-
``test_translations_are_checked_against_current_english`` red until a
1006-
human confirms and runs ``scripts/update_locale_baseline.py`` (the manual
1005+
``test_translations_are_checked_against_current_english`` — run by the
1006+
locale-sync workflow's completeness verification — red until a human
1007+
confirms and runs ``scripts/update_locale_baseline.py`` (the manual
10071008
repin, which is that confirmation).
10081009
"""
10091010
current = module.english_sources()

src/ha_mcp/settings_ui/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Any reachable HA instance works for `HOMEASSISTANT_URL` / `HOMEASSISTANT_TOKEN`.
3737
- `settings.html` — the page markup. `__HA_MCP_CSS__` and `__HA_MCP_JS__` are filled once at import. `__HA_MCP_THEME_PREFS__`, `__HA_MCP_I18N__`, `__HA_MCP_LANG__`, and `__HA_MCP_DIR__` are filled per request by `_render_settings_html()`.
3838
- `settings.js` — the client script, injected into `<script>__HA_MCP_JS__</script>`. Not served as a separate asset.
3939
- `settings.css` — the stylesheet, injected into `<style>__HA_MCP_CSS__</style>`.
40-
- `_i18n.py` and `locales/*.json` — auto-discovered translation catalogs. English is the per-key fallback for `messages`. Adding a language is not a single-file change: it ships on all four translated surfaces or not at all, its `tool_groups` and `tools` sections are mandatory and exact, and `en.json` is the wrong starting point because it carries both of those empty. See `locales/README.md` for the procedure and the repository-root `AGENTS.md` § Translations for the rules CI enforces. The wheel, sdist and binary declarations match the locale directory by pattern, so a new catalog needs no packaging edit — but keep those patterns intact when touching packaging.
40+
- `_i18n.py` and `locales/*.json` — auto-discovered translation catalogs. English is the per-key fallback for `messages`. Adding a language is not a single-file change: it ships on all four translated surfaces or not at all. Its `tool_groups` and `tools` sections must end up exact — the post-merge locale-sync workflow fills and verifies them, so a `meta`-only stub may merge — and `en.json` is the wrong starting point because it carries both of those empty by design. See `locales/README.md` for the procedure and the repository-root `AGENTS.md` § Translations for the rules CI enforces. The wheel, sdist and binary declarations match the locale directory by pattern, so a new catalog needs no packaging edit — but keep those patterns intact when touching packaging.
4141

4242
## Gotchas (read before editing)
4343

src/ha_mcp/settings_ui/locales/README.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ Home Assistant language code names every file:
2525
- `homeassistant-addon-dev/translations/<code>.yaml` (generated)
2626

2727
Add the two authored catalogs — this one may start as a `meta`-only stub
28-
(`native_name`, `dir`) — then run `python scripts/generate_locales.py` and let
29-
`scripts/translate_locales.py` (or the `locale-sync.yml` workflow on the PR)
30-
machine-fill every string; review its output like any other diff. Also
31-
add the new code to the locale list in the repository-root `AGENTS.md`
28+
(`native_name`, `dir`) — then run `python scripts/generate_locales.py` and
29+
merge: the post-merge `locale-sync.yml` workflow machine-fills every string
30+
over its next daily runs. To fill them in your own PR instead, run
31+
`scripts/translate_locales.py` yourself and review its output like any
32+
other diff. Also add the new code to the locale list in the repository-root
33+
`AGENTS.md`
3234
§ Translations — that list is pinned by
3335
`test_agents_md_lists_every_shipped_locale`. The engine reads the target
3436
language from `meta.native_name`, so any language an LLM can write — natural
@@ -69,21 +71,30 @@ import, so the failure names the file but arrives as a broken test module:
6971

7072
## What CI checks
7173

74+
In PR CI (`tests/src/unit/test_locale_parity.py`, ungated):
75+
7276
- Every surface carries the same set of language codes.
73-
- `tool_groups` and `tools` name exactly the renderable groups and tools — a
74-
tool added to the codebase turns every locale red in the PR that adds it.
77+
- The generated files (both add-on YAMLs, `FEATURE_META`) are byte-exact
78+
generator output (`test_derived_catalogs_match_the_canonical_store`); run
79+
`python scripts/generate_locales.py` after touching any `addon.*`,
80+
`addon_stable.*` or `features.*` key.
81+
- Component-catalog `{placeholder}` parity, for keys whose English still
82+
matches the baseline — a hand edit that drops a placeholder fails the PR
83+
that makes it; a translation awaiting a machine rewrite is excluded.
84+
85+
In the post-merge `locale-sync.yml` workflow only (the same test file, gated
86+
behind `LOCALE_COMPLETENESS_CHECKS=1` — a PR that changes English merges
87+
without these, and the daily sync owes them afterwards):
88+
89+
- `tool_groups` and `tools` name exactly the renderable groups and tools.
7590
- At most 5% of this catalog's `messages`, and 5% of its `tools` texts, may be
7691
byte-identical to English or missing outright; the component catalogs allow
7792
15%, because they carry product names as keys of their own. A single tool
7893
whose `title` *and* `description` are both still English fails by name
7994
however small the share.
80-
- The generated files (both add-on YAMLs, `FEATURE_META`) are byte-exact
81-
generator output (`test_derived_catalogs_match_the_canonical_store`); run
82-
`python scripts/generate_locales.py` after touching any `addon.*`,
83-
`addon_stable.*` or `features.*` key.
8495
- The English each translation was written against is hashed in
8596
`tests/src/unit/locale_source_baseline.json`, so a later edit to an English
86-
string turns the locales red rather than leaving them silently stale
97+
string reads as stale rather than silently keeping the old meaning
8798
`scripts/translate_locales.py` retranslates exactly those keys and repins
8899
the baseline. Adding a language does not change any English source, so no
89100
baseline regeneration is needed for it.

0 commit comments

Comments
 (0)