-
Notifications
You must be signed in to change notification settings - Fork 177
chore(ci): automate translation maintenance #3796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
4e207ae
960fae4
012f2ee
54ecf4a
0679e58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| name: Translations Check | ||
|
|
||
| # Creates translation PRs for languages that have missing `msgstr` entries | ||
| # or are affected by newly-added rules in src/frontend/src/lib/locales/CLAUDE.md. | ||
| on: | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - "src/frontend/src/lib/locales/**.po" | ||
| - "src/frontend/src/lib/locales/CLAUDE.md" | ||
| - "src/frontend/src/lib/locales/rules/**.md" | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
|
|
||
| concurrency: | ||
| group: translations-check | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| precheck: | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| needs_run: ${{ steps.decide.outputs.needs_run }} | ||
| rule_diff_range: ${{ steps.decide.outputs.rule_diff_range }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Decide whether to invoke Claude | ||
| id: decide | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| EVENT_BEFORE: ${{ github.event.before }} | ||
| EVENT_AFTER: ${{ github.event.after }} | ||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| # Trigger 1: rule file(s) changed in the pushed commit range (new rule merged)? | ||
| rule_diff_range="" | ||
| if [ "$EVENT_NAME" = "push" ] && [ -n "${EVENT_BEFORE:-}" ] && [ -n "${EVENT_AFTER:-}" ]; then | ||
| rule_diff_range="$EVENT_BEFORE..$EVENT_AFTER" | ||
| if git diff --quiet "$EVENT_BEFORE" "$EVENT_AFTER" -- src/frontend/src/lib/locales/CLAUDE.md src/frontend/src/lib/locales/rules/; then | ||
| rules_changed=false | ||
| else | ||
| rules_changed=true | ||
| fi | ||
|
sea-snake marked this conversation as resolved.
Outdated
|
||
| else | ||
| rules_changed=false | ||
| fi | ||
|
|
||
| # Trigger 2: any .po file on main has empty msgstr and no open PR for it? | ||
| open_pr_branches=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --search "chore(fe): update" --json headRefName --jq '.[].headRefName' 2>/dev/null || true) | ||
|
|
||
| has_missing=false | ||
| for po in src/frontend/src/lib/locales/*.po; do | ||
| lang=$(basename "$po" .po) | ||
| [ "$lang" = "en" ] && continue | ||
| if echo "$open_pr_branches" | grep -qx "chore/translate-$lang"; then | ||
| continue | ||
| fi | ||
| # Skip the header entry (msgid "") then look for empty msgstr | ||
| if awk '/^msgid ""$/{skip=1;next}/^msgid /{skip=0}{if(!skip)print}' "$po" | grep -q '^msgstr ""$'; then | ||
| has_missing=true | ||
| break | ||
| fi | ||
| done | ||
|
|
||
| # Always run on workflow_dispatch | ||
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | ||
| needs_run=true | ||
| elif [ "$rules_changed" = true ] || [ "$has_missing" = true ]; then | ||
| needs_run=true | ||
| else | ||
| needs_run=false | ||
| fi | ||
|
|
||
| echo "rules_changed=$rules_changed" | ||
| echo "has_missing=$has_missing" | ||
| echo "needs_run=$needs_run" | ||
| echo "rule_diff_range=$rule_diff_range" | ||
| echo "needs_run=$needs_run" >> "$GITHUB_OUTPUT" | ||
| echo "rule_diff_range=$rule_diff_range" >> "$GITHUB_OUTPUT" | ||
|
|
||
| run-claude: | ||
| needs: precheck | ||
| if: needs.precheck.outputs.needs_run == 'true' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| token: ${{ secrets.GIX_BOT_PAT }} | ||
| fetch-depth: 0 | ||
|
|
||
| - uses: ./.github/actions/setup-node | ||
|
|
||
| - run: npm ci | ||
|
|
||
| - name: Configure git identity | ||
| run: | | ||
| git config --global user.name "gix-bot" | ||
| git config --global user.email "gix-bot@users.noreply.github.com" | ||
|
|
||
| - name: Install Claude Code CLI | ||
| run: npm install -g @anthropic-ai/claude-code | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking — unpinned All three workflows run Consider pinning to a specific version (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pinned
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed — pin applied across all three files. Looks good. |
||
|
|
||
| - name: Run Claude | ||
| env: | ||
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | ||
| GH_TOKEN: ${{ secrets.GIX_BOT_PAT }} | ||
| RULE_DIFF_RANGE: ${{ needs.precheck.outputs.rule_diff_range }} | ||
| run: | | ||
| prompt=$(envsubst '${RULE_DIFF_RANGE}' < src/frontend/src/lib/locales/prompts/check.md) | ||
| claude -p "$prompt" \ | ||
| --dangerously-skip-permissions \ | ||
| --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Agent" | ||
|
|
||
| - name: Notify Slack on failure | ||
| if: ${{ failure() }} | ||
| uses: ./.github/actions/slack | ||
| with: | ||
| WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | ||
| MESSAGE: "Translations check failed: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| name: Translations Context Audit | ||
|
|
||
| # Scans Svelte sources for translatable strings that are ambiguous without a | ||
| # translator-facing `context` annotation (e.g. short labels like "Cancel" used | ||
| # in several places with different meanings) and opens a single sweep PR that | ||
| # converts them to `$t({ message, context })` form. | ||
| # | ||
| # Runs weekly to pick up newly-added ambiguous strings. `workflow_dispatch` is | ||
| # also available for on-demand sweeps. Reviewer feedback on the resulting PR is | ||
| # handled by the translations-feedback workflow. | ||
|
|
||
| on: | ||
| schedule: | ||
| # Mondays at 10:00 UTC | ||
| - cron: "0 10 * * 1" | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
|
|
||
| concurrency: | ||
| group: translations-context-audit | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| run-claude: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| token: ${{ secrets.GIX_BOT_PAT }} | ||
| fetch-depth: 0 | ||
|
|
||
| - uses: ./.github/actions/setup-node | ||
|
|
||
| - run: npm ci | ||
|
|
||
| - name: Configure git identity | ||
| run: | | ||
| git config --global user.name "gix-bot" | ||
| git config --global user.email "gix-bot@users.noreply.github.com" | ||
|
|
||
| - name: Install Claude Code CLI | ||
| run: npm install -g @anthropic-ai/claude-code | ||
|
|
||
| - name: Run Claude | ||
| env: | ||
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | ||
| GH_TOKEN: ${{ secrets.GIX_BOT_PAT }} | ||
| run: | | ||
| claude -p "$(cat src/frontend/src/lib/locales/prompts/context-audit.md)" \ | ||
| --dangerously-skip-permissions \ | ||
| --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Agent" | ||
|
|
||
| - name: Notify Slack on failure | ||
| if: ${{ failure() }} | ||
| uses: ./.github/actions/slack | ||
| with: | ||
| WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | ||
| MESSAGE: "Translations context audit failed: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| name: Translations Feedback | ||
|
|
||
| # Applies reviewer feedback on translation PRs and (for broad feedback) | ||
| # opens rule-proposal PRs that, once merged, trigger Translations Check. | ||
| on: | ||
| pull_request_review: | ||
| types: [submitted] | ||
| pull_request_review_comment: | ||
|
sea-snake marked this conversation as resolved.
|
||
| types: [created] | ||
| issue_comment: | ||
| types: [created] | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
|
|
||
| concurrency: | ||
| group: translations-feedback-pr-${{ github.event.pull_request.number || github.event.issue.number }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| gate: | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| run_claude: ${{ steps.gate.outputs.run_claude }} | ||
| pr_number: ${{ steps.gate.outputs.pr_number }} | ||
| pr_head_ref: ${{ steps.gate.outputs.pr_head_ref }} | ||
| pr_head_repo: ${{ steps.gate.outputs.pr_head_repo }} | ||
| steps: | ||
| - name: Gate | ||
| id: gate | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| COMMENT_USER: ${{ github.event.comment.user.login || github.event.review.user.login }} | ||
| ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} | ||
| PR_NUMBER_DIRECT: ${{ github.event.pull_request.number }} | ||
| ISSUE_NUMBER: ${{ github.event.issue.number }} | ||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| # issue_comment fires for both issues and PRs — require pull_request presence. | ||
| if [ "$EVENT_NAME" = "issue_comment" ] && [ -z "${ISSUE_PR_URL:-}" ]; then | ||
| echo "run_claude=false" >> "$GITHUB_OUTPUT" | ||
| echo "Not a PR comment — skipping" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Allowlist (same as the previous launchd scripts). | ||
| case "$COMMENT_USER" in | ||
| AntonioVentilii|aterga|sea-snake|marc0olo|mducroux|copilot-pull-request-reviewer|copilot-pull-request-reviewer\[bot\]|Copilot) ;; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking — Copilot in the commenter allowlist creates an indirect injection path Including The fork-rejection gate and PR title filter narrow this considerably — the attacker needs direct push access to the repo. If Worth noting as a defense-in-depth consideration. One option: exclude Copilot from this allowlist and rely on human reviewers to relay feedback that Claude should act on.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed this is a real defense-in-depth consideration. Keeping Copilot in the allowlist for now because it's a material part of the review loop (Copilot catches things humans miss and translation PRs historically surface issues that way). The risk is bounded by: (1) push access required — external contributors can't seed injection content, (2) the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rationale is sound. With the |
||
| *) | ||
| echo "run_claude=false" >> "$GITHUB_OUTPUT" | ||
| echo "Commenter '$COMMENT_USER' not in allowlist — skipping" | ||
| exit 0 | ||
| ;; | ||
| esac | ||
|
|
||
| # Avoid self-trigger loops from gix-bot's own replies. | ||
| if [ "$COMMENT_USER" = "gix-bot" ]; then | ||
|
sea-snake marked this conversation as resolved.
Outdated
|
||
| echo "run_claude=false" >> "$GITHUB_OUTPUT" | ||
| echo "Self-reply — skipping" | ||
| exit 0 | ||
| fi | ||
|
|
||
| pr_number="${PR_NUMBER_DIRECT:-$ISSUE_NUMBER}" | ||
| if [ -z "$pr_number" ]; then | ||
| echo "run_claude=false" >> "$GITHUB_OUTPUT" | ||
| echo "No PR number resolvable — skipping" | ||
| exit 0 | ||
| fi | ||
|
|
||
| pr_json=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number") | ||
| pr_title=$(echo "$pr_json" | jq -r .title) | ||
| pr_head_ref=$(echo "$pr_json" | jq -r .head.ref) | ||
| pr_head_repo=$(echo "$pr_json" | jq -r .head.repo.full_name) | ||
| pr_state=$(echo "$pr_json" | jq -r .state) | ||
| pr_merged=$(echo "$pr_json" | jq -r .merged) | ||
|
|
||
|
sea-snake marked this conversation as resolved.
|
||
| # Only run on translation PRs. | ||
| if ! echo "$pr_title" | grep -q '^chore(fe):'; then | ||
| echo "run_claude=false" >> "$GITHUB_OUTPUT" | ||
| echo "PR title '$pr_title' is not a translation PR — skipping" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # For closed PRs, only consider those merged within the last 14 days | ||
| # (matching the previous launchd behaviour). | ||
| if [ "$pr_state" = "closed" ] && [ "$pr_merged" = "true" ]; then | ||
| merged_at=$(echo "$pr_json" | jq -r .merged_at) | ||
| cutoff=$(date -u -d '14 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-14d +%Y-%m-%dT%H:%M:%SZ) | ||
| if [ "$merged_at" \< "$cutoff" ]; then | ||
| echo "run_claude=false" >> "$GITHUB_OUTPUT" | ||
| echo "PR merged before $cutoff — skipping" | ||
| exit 0 | ||
| fi | ||
| elif [ "$pr_state" = "closed" ]; then | ||
| echo "run_claude=false" >> "$GITHUB_OUTPUT" | ||
| echo "PR is closed without merge — skipping" | ||
| exit 0 | ||
| fi | ||
|
|
||
| echo "run_claude=true" >> "$GITHUB_OUTPUT" | ||
| echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" | ||
| echo "pr_head_ref=$pr_head_ref" >> "$GITHUB_OUTPUT" | ||
| echo "pr_head_repo=$pr_head_repo" >> "$GITHUB_OUTPUT" | ||
|
|
||
| run-claude: | ||
| needs: gate | ||
| if: needs.gate.outputs.run_claude == 'true' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| token: ${{ secrets.GIX_BOT_PAT }} | ||
| fetch-depth: 0 | ||
|
|
||
| - uses: ./.github/actions/setup-node | ||
|
|
||
| - run: npm ci | ||
|
|
||
| - name: Configure git identity | ||
| run: | | ||
| git config --global user.name "gix-bot" | ||
| git config --global user.email "gix-bot@users.noreply.github.com" | ||
|
|
||
| - name: Install Claude Code CLI | ||
| run: npm install -g @anthropic-ai/claude-code | ||
|
|
||
| - name: Run Claude | ||
| env: | ||
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | ||
| GH_TOKEN: ${{ secrets.GIX_BOT_PAT }} | ||
| PR_NUMBER: ${{ needs.gate.outputs.pr_number }} | ||
| PR_HEAD_REF: ${{ needs.gate.outputs.pr_head_ref }} | ||
| PR_HEAD_REPO: ${{ needs.gate.outputs.pr_head_repo }} | ||
| run: | | ||
| prompt=$(envsubst '${PR_NUMBER} ${PR_HEAD_REF} ${PR_HEAD_REPO}' < src/frontend/src/lib/locales/prompts/feedback.md) | ||
| claude -p "$prompt" \ | ||
| --dangerously-skip-permissions \ | ||
| --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Agent" | ||
|
|
||
| - name: Notify Slack on failure | ||
| if: ${{ failure() }} | ||
| uses: ./.github/actions/slack | ||
| with: | ||
| WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | ||
| MESSAGE: "Translations feedback failed on PR #${{ needs.gate.outputs.pr_number }}: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # Translation rules | ||
|
|
||
| This file is the index. Individual rule sets live in [`rules/`](./rules/) and are imported below. Add a new rule by appending to the relevant `rules/<topic>.md` file — or creating a new one and adding an `@rules/...` line here. | ||
|
|
||
| Rules are added via "rule proposal" pull requests (title prefix `chore(fe): propose translation rule`). Merging such a PR signals approval and triggers a sweep of existing translations against the new rule. | ||
|
|
||
| ## General (applies to all languages) | ||
|
|
||
| @rules/general.md | ||
|
|
||
| ## Language-specific | ||
|
|
||
| @rules/russian.md |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of hard coding this, could we take members of the Identity team?