feat(i18n): automated translation pipeline with machine review gate#623
feat(i18n): automated translation pipeline with machine review gate#623Timur Tukaev (tym83) wants to merge 28 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces an automated, machine-reviewed translation pipeline for the Cozystack website, adding localization scripts, prompts, style guides, and tests under hack/i18n/, alongside initial support for Spanish and Brazilian Portuguese. It also integrates a machine-translation disclaimer banner into the documentation layout and adds hreflang alternates for SEO. Feedback on these changes highlights a non-standard Hugo API usage (.Sites.Default) in the translation banner, a duplicate entry in .gitignore, and formatting conflicts in the translation and revision prompts where the instructions ask for key: value lines instead of the JSON format expected by the parsing script.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| {{- $src := .Sites.Default.Language.Lang -}} | ||
| {{- if and (ne .Lang $src) (ne .Params.translation_review "ratified") -}} |
There was a problem hiding this comment.
.Sites.Default is not a standard Hugo API and may cause template evaluation errors depending on the Hugo version. The standard and safe way to retrieve the default content language is .Site.DefaultContentLanguage.
| {{- $src := .Sites.Default.Language.Lang -}} | |
| {{- if and (ne .Lang $src) (ne .Params.translation_review "ratified") -}} | |
| {{- $src := .Site.DefaultContentLanguage -}} | |
| {{- if and (ne .Lang $src) (ne .Params.translation_review "ratified") -}} |
| # i18n pipeline venv (bootstrapped by hack/i18n/run-daily.sh) | ||
| .venv-i18n/ | ||
|
|
||
| # i18n pipeline: per-run artifacts, not content | ||
| hack/i18n/last-run-findings.md | ||
| .venv-i18n/ |
There was a problem hiding this comment.
| ===FRONTMATTER=== | ||
| <the translated values, one `key: value` per line, same keys, same order> | ||
| ===BODY=== | ||
| <the translated body> |
There was a problem hiding this comment.
The system prompt instructs the model to output the front matter as key: value lines, but the translation pipeline (translate.py via _FM_PROTOCOL) expects a JSON object. This conflict can confuse the model and lead to protocol parsing errors. Updating the prompt to match the JSON protocol will improve reliability.
| ===FRONTMATTER=== | |
| <the translated values, one `key: value` per line, same keys, same order> | |
| ===BODY=== | |
| <the translated body> | |
| ===FRONTMATTER=== | |
| <the translated values as a JSON object, same keys, same order> | |
| ===BODY=== | |
| <the translated body> |
| ===FRONTMATTER=== | ||
| <corrected key: value lines, same keys, same order> | ||
| ===BODY=== | ||
| <corrected body> |
There was a problem hiding this comment.
Similar to the translation prompt, the revision system prompt instructs the model to output key: value lines for the front matter, which conflicts with the JSON object format expected by _FM_PROTOCOL in translate.py. Updating this to instruct the model to return a JSON object will prevent protocol errors during the revision loop.
| ===FRONTMATTER=== | |
| <corrected key: value lines, same keys, same order> | |
| ===BODY=== | |
| <corrected body> | |
| ===FRONTMATTER=== | |
| <corrected JSON object, same keys, same order> | |
| ===BODY=== | |
| <corrected body> |
In-tree localization engine for cozystack.io (hack/i18n/), building on the existing source_digest freshness convention (hack/check-i18n.sh): - worklist.py: diff detector (missing/stale pages via source_digest) - translate.py: Claude Opus translator with glossary, per-language style guides, protected code/shortcodes/URLs, SEO front-matter transcreation - ahrefs_keywords.py: per-locale SEO keyword maps (optional, degrades gracefully without AHREFS_API_KEY) - i18n-translate.yml: nightly + dispatch; PR then auto-merge (publish-then-review) - config.yaml: languages, model routing (all Opus), scope, blog cutoff (last ~2 months), publish mode Add Spanish (es) and Portuguese-BR (pt-br): hugo.yaml + production mounts, i18n/es.toml + i18n/pt-br.toml (key parity verified). hreflang alternates + x-default in head-end.html. CODEOWNERS exempts content/<lang>/ so the pipeline auto-merges translations while code stays gated. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
… OAuth, daily runner Rework the pipeline per maintainer decisions: - Auth: OAuth subscription (Max), not a metered API key — bare Anthropic() client resolves the logged-in credential; ANTHROPIC_API_KEY warns. - Machine review gate on EVERY page before publish: translate -> back-translate + meaning-drift compare -> two native reviewers (technical editor for fluency, Cozystack maintainer for technical correctness) -> revise-if-needed, bounded by review.max_rounds. Pages written atomically only after passing. - Daily-until-limit: run stops cleanly on 429 and resumes next day; adds hack/i18n/run-daily.sh (commit + daily PR + auto-merge). - New prompts: back-translate(-compare), review-editor, review-maintainer, revise. config.yaml gains auth/rate_limit/back_translation/review sections and a translation_review front-matter stamp. - Workflow switched off metered API key to optional CLAUDE_CODE_OAUTH_TOKEN; local daily runner is the primary path. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
The base anthropic SDK cannot use a Claude subscription (metered API only), so the model-call layer now uses the Claude Agent SDK (claude-agent-sdk), which reads CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`. - translate.py: call() runs a single-turn, tool-less Agent SDK query (allowed_tools=[], max_turns=1) via asyncio; hard-fails if ANTHROPIC_API_KEY is set (it would shadow the subscription); warns if CLAUDE_CODE_OAUTH_TOKEN is missing; rate-limit detection maps to a clean daily stop. - run-daily.sh: require CLAUDE_CODE_OAUTH_TOKEN, forbid ANTHROPIC_API_KEY, install claude-agent-sdk. - workflow: CLAUDE_CODE_OAUTH_TOKEN secret + install claude-agent-sdk and the claude-code CLI. - config.yaml/README: document the Agent SDK subscription path + setup. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Verified on a real machine: the Agent SDK authenticates off an existing `claude` CLI login — CLAUDE_CODE_OAUTH_TOKEN is only needed headless/CI. So: - run-daily.sh no longer hard-fails without the token; it requires only that ANTHROPIC_API_KEY is unset (the money guard) and that some subscription credential exists (claude login or token). - run-daily.sh bootstraps a venv (.venv-i18n) and runs the pipeline from it — distro Pythons are PEP 668 externally-managed, so plain pip install fails. - translate.py: drop the misleading missing-token warning. - gitignore the venv. Smoke-tested end to end: claude-agent-sdk 0.2.121, single-turn tool-less query against claude-opus-4-8 returns text over the subscription. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Ahrefs API v3 access is an expensive, separately-licensed add-on, and the English source is already SEO-optimized during authoring — the translation inherits that intent. The translate prompt still transcreates title/description to read naturally per locale, so nothing is lost by removing the keyword step. Removes ahrefs_keywords.py, keyword-maps/, the ahrefs config block, the keyword-hint injection in translate.py, the workflow step, and the AHREFS_API_KEY secret. No secrets are required for translation now beyond the subscription credential. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
…injection Adversarial review found the pipeline did not actually work and that its central claim was not enforced by code. Fixes: P0 publish: run-daily.sh used "git diff --quiet", which ignores untracked files — every new translation is untracked, so it reported "nothing to publish" forever while source_digest on disk made the page look fresh the next day (silent permanent no-op). Now uses "git status --porcelain" scoped to content/<lang>, branches from a fresh origin/main (days no longer stack), and honours publish_mode (pr_only was documented but never implemented). P0 gate honesty: _parse_verdict failed OPEN — refusals, prose and truncated JSON all silently passed, making the gate unfalsifiable; and the revise loop stamped pages that never cleared it as auto-reviewed. Now fails CLOSED (unparseable = revise), a missing prompt file hard-fails instead of sending a reviewer out with no instructions, and pages are stamped honestly (auto-reviewed vs auto-reviewed-with-findings) with per-run counters. P0/P1 correctness: reject replies without the ===BODY=== protocol instead of writing the model's preamble as page content; verify every protected placeholder survived (a dropped fence silently deleted a code block); hard-fail on unparseable front matter instead of dropping slug/date/aliases. P1 scope: the docs version is now read from hugo.yaml (latest_version_id) instead of hardcoded v1.4 while latest is v1.5 — 164 of 352 pages were being translated into a noindex'd version. Scope drops 352 -> 188 pages (2096 -> 1128 jobs). fnmatch replaced with pathlib semantics so the config no longer lies about what it matches. P1 security: the workflow interpolated github.event.inputs into the shell, letting anyone with write access exfiltrate the subscription token; inputs now go through env and are quoted. P1 auth: auth is now a config choice (oauth-subscription | api-key) so the project can move from a maintainer's subscription to an org-owned key without code changes. P2: read the whole front matter for source_digest (a long one made pages look permanently stale and re-translate daily); unknown --lang errors instead of silently doing nothing; stable YAML dump; drop dead config/code; stale v1.2 default in head-end.html. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Resolves the governance objection from review: nothing about CODEOWNERS or branch protection changes any more, and no machine output reaches the production site without a maintainer merging it. - CODEOWNERS: reverted to the original single rule. The previous exemption for content/<lang>/ would have removed required review from those paths for ANY author, not just the bot — on a tree where goldmark renders unsafe HTML. - publish_mode: pr_only is now the default and is actually honoured. - Cadence: the runner still runs DAILY (each day's quota is used in full), but the week's output accumulates onto one i18n/week-<ISO week> branch, so maintainers review and merge a single translation PR per week instead of a stream. A new week branches fresh from origin/main. - Docs: README documents the daily-run/weekly-PR split, the honest auto-reviewed vs auto-reviewed-with-findings stamps, and an "Ownership and continuity" section stating the intent to move from a maintainer's subscription to an organization-owned API key. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Second review pass over the pipeline. The findings that mattered were all silent-failure modes: the run would look successful while producing wrong or no output. Nested front matter. The landing page renders its hero and cards from taglines[], benefits[], and features[]; only top-level keys were translated, so regenerating a localized homepage would copy the English hero back over a hand-translated one and drop locale-only keys (seo:, l10n:) that natives had added. Front-matter values are now addressed by path, translated at any depth, and applied onto a deep copy, with target-only keys re-attached. The front-matter wire format moves from "key: value" lines to JSON, which also fixes multi-line descriptions being shredded by the line parser. Fail closed. A "revise" verdict with an empty findings list no longer passes the gate; latest_docs_version raises instead of returning None (which made the scope filter a no-op and pulled in every old docs version); a duplicated placeholder is rejected like a dropped one; a missing front-matter key is rejected rather than shipping a page whose body is translated but whose hero is still English. run-daily.sh no longer stashes in the working tree — it requires a clean dedicated clone and restores the starting branch on exit. Stashing wrote conflict markers into translations and could pop someone else's stash. Also fixes a BrokenPipeError that killed the run before the first page, and stops treating normal check-i18n.sh staleness as a publish blocker. Tests cover the pure functions. One of them corrected a docstring that claimed path globbing narrowed `*.md` to the repo root; it does not, and the config does not want it to. The GitHub workflow is removed: the pipeline runs from a maintainer's clone, and a workflow implied CI-hosted credentials we deliberately do not use. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Localized pages are indexed from day one so readers get the docs now instead of after a native review that may take months. That trade is only honest if the page says what it is, and until now nothing did. Add a translation-banner partial that carries a machine-translation notice and links to the English original. The rule is fail-safe: it shows on any non-English page unless the front matter says `translation_review: ratified`. Keying off the field's presence instead would have exempted every page from the i18n PoC, which is machine output with no such field — precisely the pages the notice exists for. Wired into the same four layouts as version-banner, so docs, blog, and pages are covered; the homepage is deliberately left out, since covering it means overriding the theme's home layout and a warning across a marketing hero costs more than it buys. Strings are localized in all seven locales. Stop declaring es and pt-br in hugo.yaml. Declaring a language with no content does not build nothing: Hugo emits /es/, /es/tags/, /es/categories/, /es/topics/, /es/article_types/ and /es/404.html regardless, all `index, follow` and self-canonical — twelve empty but indexable pages across the two, which is the thin content the rest of this work is careful to avoid. They are commented out in hugo.yaml and config.yaml together, with the enable order documented: translate first, then declare, in the PR that carries the content. The pipeline now writes `l10n: mt`, reusing the site's existing convention for how a page was localized rather than inventing another field. README documents what each of the three front-matter fields means and who reads it. README also now says plainly what the review gate is not: both "native reviewers" are the same model as the translator with different prompts, which measures self-consistency, not native ratification. `auto-reviewed` must not be read as "a human checked this" — only a human sets `ratified`, and only that drops the banner. Adds a rollback section, since a pipeline that publishes to production needs a documented way to stop. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
… scope The preferred-terms list covered de, ru, es, and pt-br. es and pt-br are not enabled; zh-cn and hi are, and had zero entries — the two live languages with nothing keeping their terminology consistent had the coverage the disabled ones got. Adds all eight terms for both. `Tenant` and `tenant` are in do_not_translate and preferred respectively, which reads as a contradiction and is not one: the capitalized term is the API kind a reader has to match against `kind: Tenant` in a manifest, the lower-case one is an ordinary noun that has to be translated or the prose is unreadable. Both the glossary and the translate prompt now say so, and a test asserts any other overlap between the lists is a real contradiction. Excludes oss-health/** — five pages that are front matter plus `layout: oss-health-app`, a dashboard rendered client-side from live English data. Translating them wraps localized chrome around an English dashboard. They also carry the site's only `lede` field, a user-visible string that was not in the translatable-key list and would have shipped in English on every localized copy; dropping these pages is what makes that list true rather than approximately true. Tests now run against the real content tree: that config.yaml and hugo.yaml agree on which languages are enabled, and that no unrecognized user-visible front-matter key has appeared. The second is the one that would have caught `lede`. Scope is now 183 pages x 4 languages = 720 jobs. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
run-daily.sh forwards the same "$@" to both scripts, so `--limit 3` made worklist.py exit with a usage error. The preview was wrapped in `|| true`, so it failed silently — and only when --limit was passed, which is exactly the pilot-run case it exists to preview. An unknown --lang printed "all languages up to date" and exited 0, since the filter simply matched nothing. It is now an error. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
A pilot run produced a page stamped `auto-reviewed-with-findings` and no record anywhere of what the findings were. The stamp told a maintainer that something was wrong and gave them nothing to act on, which makes the distinction between it and `auto-reviewed` close to useless. translate_page now returns the findings still open on the final round. The runner prints them per page and writes a markdown report, which run-daily.sh posts as a PR comment. A comment, not the PR body: the report file holds only the last run, while the PR accumulates a week of daily runs — rewriting the body each day would drop the earlier days' findings. Comments accumulate on their own, so the thread becomes the week's log. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Two pilot runs on the same release blog post: de cleared the gate in 2m54s, ru took 11m24s through the revise loop. Per-page cost is dominated by whether the revise loop runs, not by the page — which puts the 720-job backlog at roughly 35-140 hours of wall clock before daily limits are even considered, and is the concrete reason the backlog needs an organization API key rather than a personal subscription. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Commenting es and pt-br out of the pipeline config stopped them being translated at all, which is not the goal — the goal is that they are not *served* until they have content. The two lists answer different questions and were wrongly kept identical. config.yaml (what gets translated) now covers all six languages. hugo.yaml (what gets built and served) still declares four; es and pt-br are declared in the PR that lands their content. The invariant is one-directional, and the test now says so: translated but not declared is how a language starts; declared but not translated means the site serves something nothing keeps fresh. A second test asserts every declared language actually has content, which is the failure that started this — Hugo emits ~6 indexable pages for a declared language with an empty content tree. Backlog is now 183 pages x 6 languages = 1084 jobs. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
A virtual reviewer on the ru pilot caught this, which is the gate doing exactly
its job: protect() masked each {{< figure >}} shortcode wholesale, so its
caption (rendered as visible text under the image) and alt (screen readers,
SEO) stayed English on every localized page — four English captions in a row on
a translated release post.
protect() now splits a shortcode into protected structure and exposed
visible-text attribute values (caption, alt, title). src, width, delimiters,
and param names stay byte-for-byte; the values translate like ordinary prose.
Shortcodes with no such attribute are still masked wholesale, unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>
Signed-off-by: tym83 <6355522@gmail.com>
Product decision: the machine-translation notice belongs where a wrong technical detail is costly — an operator running a translated command — not on the blog, marketing pages, or the homepage hero. Wired into docs/baseof.html only; removed from page/single, blog/baseof, and resources/list. The partial's guard is unchanged and layout-agnostic, so coverage is now purely a question of which layouts call it. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
A pilot on the v1.4 release blog post (63 opaque placeholders, 49 of them inline code) had the model non-deterministically drop five of them. The gate correctly refused to write the page — a silently deleted code block is worse than a retry — but a page that fails this way on every daily run would never publish while re-spending quota each day. translate.py now retries a page a few times within the run on a protocol error, since the loss is non-deterministic and usually clears on a fresh attempt. A page that fails every attempt is skipped as before and stays in the worklist; README documents that a reproducible failure means translate that page by hand. Adds --path to translate one exact page (pilot runs, or re-translating a single page after editing its source). Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com>
Two pipeline outputs for reviewers to judge quality on, both of the same source
page (docs/v1.5/getting-started/install-kubernetes.md) so they can be compared
side by side. Both cleared the review gate (translation_review: auto-reviewed);
inline code, the {{% ref %}} shortcode, and front-matter structure are preserved
verbatim, and each carries the machine-translation banner shown on docs pages.
These are real, reviewable output — not fixtures. The English source is
untouched.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>
Signed-off-by: tym83 <6355522@gmail.com>
…rklist _docs_out_of_scope excluded every docs page not under docs/<latest>/, which also dropped docs/_index.md — the version-picker landing that #593 translates per language and records a source_digest for. The freshness lint would then flag drift on that page with no pipeline path to refresh it. Narrow only the versioned subtrees (docs/<ver>/...) to the latest version; pages directly under docs/ are version-agnostic and stay in scope. Adjust the leak-in guard test accordingly and cover the landing explicitly. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
The docs version-picker landing strings were added to the active-language i18n files on poc/i18n-multilang (#593). es and pt-br live only on this pipeline branch, so the rebase onto that work left them without those keys — the parity lint flags them as missing. Add the Spanish and Portuguese translations so all seven language files stay in parity. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
f91315f to
4653ba7
Compare
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
Reviewed this PR's own diff (36 files) against its base, with #593 now merged. Ran hack/i18n/test_i18n.py (49 pass) and hack/check-i18n.sh (clean). The design is careful and the README is unusually honest about what the gate is and isn't — nearly all of my comments are about the runner, not the translation logic.
Blocking: the daily runner wedges on its second run
run-daily.sh translates on whatever branch the clone happens to be on, publishes on a different one, and the EXIT trap restores the original branch (hack/i18n/run-daily.sh:79-81, 115-126). In order:
- Day 1 commits translations onto
i18n/week-<N>and pushes. The trap then checks outSTART_REF, which deletes those files from the working tree — they now only exist on the week branch. - Day 2 therefore sees them as
missingagain (build_worklistchecks the filesystem, lib.py:197-208) and re-translates the exact same pages, spending a day of quota redoing day 1. - Publishing then fails hard: the re-translated files are untracked,
origin/i18n/week-<N>already contains them, andgit checkout -B "$BRANCH" origin/$BRANCH(run-daily.sh:121) aborts with "The following untracked working tree files would be overwritten by checkout". Underset -ethe run dies leaving the tree dirty, so every later run stops at the clean-tree preflight (run-daily.sh:74-78).
I reproduced this in a scratch repo following the exact command sequence: day 2 exits 1 at the checkout and leaves ?? content/ru/ behind. The first run of a new week hits the same wall through the origin/main path once the previous week's PR is merged.
Root cause is that the working tree is never synced to the remote — git fetch (line 115) updates refs only, so both the English sources and the already-published translations stay frozen at clone time, and worklist correctness depends on that tree.
Suggestion: check out the week branch before translating — fetch, git checkout -B "$BRANCH" origin/$BRANCH (or origin/main), then run translate.py, then commit in place. That makes the tree the branch the work belongs to, keeps day 2's worklist accurate, removes the untracked-file collision, and refreshes content/en in the same move.
Worth fixing
The revise loop's last round is never re-reviewed. translate.py:271-319 — on the final iteration a page with findings is revised and the loop then exits. The text written is post-revision, but findings (stamped, returned, and posted to the weekly PR) describe the pre-revision text. So the finding list in the PR comment doesn't correspond to the pages actually shipped, and the last Opus call for each failing page produces output nothing ever checks. Either re-check after the last revise, or skip revising on it.
"A page is only written once it passes the gate" (README.md:35, config.yaml:115) contradicts the code and the rest of the README: pages that exhaust the revise loop are written, stamped -with-findings (translate.py:342-346, README.md:249-252). The diagram at README.md:13-33 has no edge for that path either. This is the sentence a reviewer leans on, so it's worth making it say what happens.
auto_merge exists. The default is pr_only and the claim in the description holds today, but run-daily.sh:170-173 will call gh pr merge --auto --squash after a one-word config change, with no second signal required. If "nothing reaches production without a maintainer merge" is meant as a property rather than a default, consider dropping that branch — nothing in this PR uses it.
Failures in the publish stage are swallowed. gh pr create ... || true and gh pr comment ... || true (lines 152-154, 167). A failed gh call leaves commits pushed, no PR opened, and exit 0 — indistinguishable from a good run in cron output.
Unpinned dependencies. pip install --quiet claude-agent-sdk pyyaml runs on every invocation (line 34), upgrading in place on a machine that holds a maintainer's CLAUDE_CODE_OAUTH_TOKEN and gh credentials. A pinned requirements.txt costs nothing here.
An i18n problem already visible in the shipped strings
The banner builds one sentence out of four keys plus link text (layouts/partials/translation-banner.html:39-42). That hardcodes English word order and punctuation, and two of the six languages already read wrong:
hi: "देखें अंग्रेज़ी मूल" — Hindi is verb-final; the natural order is "अंग्रेज़ी मूल देखें".de/ru: "Einen Fehler entdeckt? eröffnen Sie ein Issue." and "Нашли ошибку? создайте issue." — lowercase after a question mark.
One key per sentence, with the link as a placeholder, lets a translator move it. Worth noting these UI strings never pass through the pipeline's review gate — only content/ does.
Smaller things
.gitignore:32and:36—.venv-i18n/listed twice.- README.md:93 says 183 × 4 = 720 jobs; config.yaml lists six languages and the description says ~1084.
- Nothing removes a translation whose English source was deleted, and
check-i18n.shonly checks digests of files that exist, so orphans accumulate silently. blog_since: "2026-05-17"is a fixed date that will quietly rot into "nothing new is in scope" — the config comment already suggests computingnow-60d.
On the security posture and the gate
No workflow is added, so the usual automation risks don't apply: no pull_request_target, no repo-held secret, no untrusted checkout. Model calls use allowed_tools=[] and max_turns=1 (translate.py:117-122), so the translator has no filesystem or tool access, and git add -- content/<lang> genuinely bounds what a run can commit. What's left is that everything runs from one person's machine on their personal subscription, with their gh token and their DCO sign-off on machine-generated commits — and that the human merge gate is procedural, since no maintainer can meaningfully review Hindi or Chinese docs in a bulk weekly PR. The README says as much and the banner is the right mitigation; I'm noting it, not objecting.
The gate is real machinery rather than a stamp: verdict parsing fails closed (translate.py:149-172), an explicit "revise with no findings" verdict still blocks (lines 288-290, 300-302), and a lost or duplicated placeholder is a hard refusal to write (lines 213-219). Its ceiling is the one the description already admits — same model, so it measures self-consistency. Worth being precise that it never blocks publication, only stamps: it's a triage classifier, not a gate.
Requesting changes for the runner bug alone; the rest is small. Noting for the record that this is a draft and was stacked on #593, which has since merged.
The runner translated on whatever branch the clone was on and only switched to the week branch to publish, while the EXIT trap restored the starting branch afterwards. Day 1's translations then vanished from the working tree (they existed only on the week branch), so day 2 saw the same pages as missing, re-translated them on a day's quota, and crashed on checkout: the re-translated files were untracked and already present on origin's week branch. Under set -e that left a dirty tree, and every later run stopped at the clean-tree preflight. Fetch and check out the week branch (or origin/main for a new week) before running translate.py, and commit in place. The worklist now sees what is already published, the untracked-file collision cannot happen, and content/en is refreshed to the branch point in the same move. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
On the final round of the revise loop a page with open findings was revised once more and then written — so the text published was post-revision while the findings stamped on it, returned to the caller, and posted to the weekly PR described the pre-revision text. The last Opus call per failing page produced output nothing ever checked. Stop revising once the rounds are exhausted: publish the text the reviewers last saw, stamped -with-findings. The findings report now always corresponds to the pages actually shipped, and no unreviewed revision can reach the tree. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
- Pin Python dependencies (requirements.txt): the runner pip-installs into its venv on every invocation on a machine holding maintainer credentials; unpinned installs would upgrade code in place next to those credentials. - Drop || true on 'gh pr create' and 'gh pr comment': commits are already pushed at that point, so a failed gh call must fail the run — cron exit 0 with pushed commits and no PR is indistinguishable from a good run. - Remove the auto_merge code path entirely. 'Nothing reaches the production site without a maintainer merge' is meant as a property of the pipeline, not a default one config word away from off. Nothing used it. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
The disclaimer banner assembled one sentence from four i18n fragments plus link text, hardcoding English word order and punctuation into the layout. Two shipped languages already read wrong: Hindi is verb-final (the natural order is 'अंग्रेज़ी मूल देखें', not 'देखें अंग्रेज़ी मूल'), and German/Russian continued in lowercase after a question mark. German also split the article from its noun phrase across two keys. Use one key per sentence with the link as a Go-template placeholder, so each translator places the link and punctuation where the language needs them. Chinese now uses full-width punctuation without a space before the link. These UI strings never pass through the pipeline's review gate (only content/ does) — they are reviewed by hand. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
Deleting an English page left its translations behind forever: the worklist only iterates English sources, so nothing ever revisited the leftovers, and check-i18n.sh could only report them. translate.py now removes translations whose English source is gone — only source_digest-stamped (pipeline-managed) files; hand-authored locale-only pages are never touched — so the weekly PR carries the deletion. blog_since also accepts a rolling '<N>d' window (today minus N days, resolved per run). The fixed ISO date would quietly rot into 'no blog post is ever in scope' as the site ages; the config now uses '60d'. Aged-out posts keep their existing translations and just stop being refreshed. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
The README claimed a page is written 'only after it clears the gate', which contradicted both the code and the rest of the document: pages that exhaust the revise rounds are written and stamped -with-findings. Say plainly that the gate triages rather than blocks, add the missing diagram edge for that path, and note in Governance that it is a triage classifier, not a gatekeeper. Also: backlog math said 183 x 4 = 720 jobs while six languages are configured (~1100 jobs, 55-220 h at the measured per-page rates); document the rolling blog window and orphan removal; drop the duplicate .venv-i18n gitignore entry and the last publish_mode reference. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
Three operational holes found in review of the runner reordering: - A new week's branch was always cut from origin/main. If last week's PR is not merged yet (maintainers do skip weeks), its translations are not on main: the worklist would re-translate all of it on a day's quota and open a second PR conflicting with the first. Base a new week on the newest still-existing week branch instead; once the older PR merges, the new PR shrinks to its own commits. - 'checkout -B origin/...' silently discarded local commits that a failed push left behind (GitHub outage on day 1 cost day 1's work). Keep a local week branch that is ahead of — or unknown to — origin. - Orphan removal trusted the English tree unconditionally: a missing or renamed content/en would mark every stamped translation orphaned and commit a massacre. Refuse to run against a missing/empty English root, and refuse to remove more than 10 orphans at once — a large batch means the tree moved, not that ten pages died. Also spell out crash recovery in the dirty-tree preflight message. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
Follow-ups from the second review round, all fail-safe refinements: - Re-push any week branch that a failed push left stranded locally, at the start of every run. Waiting for new work to trigger the next push was not enough: with an empty backlog the run exits before publishing, and at a week boundary the new branch bases on origin refs — either way a locally-committed day sat unpublished and, across weeks, was re-translated. A branch missing on origin is only pushed when its PR state says it was never merged or closed: re-pushing a merged-and-deleted branch would resurrect it every morning and the new-week base selection would stack all later weeks on the zombie, freezing this clone's content/en at that week's snapshot. Merged/closed zombies are deleted locally instead. - The mass-deletion floor now counts distinct English pages, not files: one deleted page fans out to one orphan per language, so a file-count floor tripped on two legitimately deleted pages across six languages. - Tighten the week-branch glob to week-YYYY-WNN so a stray alphabetic branch (i18n/week-test) cannot sort above the dated ones and become a base. - README: to discard a week, close the PR and delete the branch — the runner resumes on branch existence, not PR state. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
❌ Deploy Preview for cozystack failed.
|
|
All findings addressed in 4653ba7..ae79b45 (8 commits). Point by point: Runner wedge (blocking). Fixed the way you suggested: the week branch is now checked out before translating (fetch +
Revise loop. The final round no longer revises — 5c1269d. The text written is always the text the reviewers last saw, and the findings posted to the weekly PR describe the pages actually shipped. README vs code. Reworded to say the gate triages rather than blocks, added the missing auto_merge is gone as a code path, not a default — b522046. Banner. One i18n key per sentence with the link as a template placeholder, all seven languages — b90a72f. Hindi is verb-final now («अंग्रेज़ी मूल देखें»), de/ru capitalize after the question mark, zh-cn uses full-width punctuation. Verified with a full Hugo build rendering the banner in ru/de/hi/zh-cn. Agreed these strings bypass the content gate — they're hand-reviewed. Smaller things — 45326b6, 62395fc: Tests: 54 pass (5 new), |
Summary
Builds the automated translation pipeline on top of the i18n proof-of-concept in #593. Where #593 established the multi-language site structure and a few hand-checked pages, this turns translation into a repeatable, reviewable process: it discovers what changed in the English source, translates it through a machine review gate, and opens a weekly PR for a maintainer to merge. Nothing reaches production without that merge.
Stacked on #593 — please review and merge that first; this PR targets
poc/i18n-multilangand will retarget tomainonce #593 lands.What
source_digest(sha256 of the English source, the same conventionhack/check-i18n.shalready enforces) drives a worklist of missing/stale pages per language. Scope is the latest docs version only, plus recent blog posts — older docs versions arenoindex, so translating them would spend budget on pages search engines ignore.caption/alttext inside shortcodes is translated (it renders to readers);src/widthare not.translation_review: auto-reviewed; one that runs out of revise rounds with findings still open is stampedauto-reviewed-with-findingsand its findings are posted to the weekly PR so a maintainer can triage them. Only a human setsratified.ratified. The banner is docs-only by design — the blog, marketing pages, and homepage hero do not carry it.i18n/week-<ISO week>branch, so maintainers review and merge a single translation PR per week.publish_mode: pr_only— CODEOWNERS and branch protection are untouched.docs/v1.5/getting-started/install-kubernetes.md) are included so quality can be judged directly.Why
The English docs are the source of truth and change constantly; native localization takes months. This lets translated docs ship and be indexed immediately (publish-then-ratify), with the banner keeping that honest, while native review happens asynchronously and is tracked per page. The review gate is not a substitute for native ratification — both virtual reviewers are the same model as the translator, so the gate measures self-consistency and catches the obvious failures, no more. It is deliberately conservative: fail-closed on unparseable reviewer verdicts, refuses to write a page with a dropped code block, and never publishes without a maintainer merge.
Notes for reviewers
auth: api-key), no code change. See README "Ownership and continuity".hugo.yaml(no content shipped). Declaring a language before its content exists publishes empty indexable pages; a test enforces the ordering.python3 hack/i18n/test_i18n.py.Preview
The included sample pages render under
/ru/docs/v1.5/getting-started/install-kubernetes/and/de/…in the deploy preview, each with the machine-translation banner.