I recently merged a pull request that changed 2096 files. Not a single file was translated by hand.

ghcertified.com — an open source project I maintain — hosts 524 practice questions for GitHub certification exams across 5 categories. The site supports 5 languages: English, Portuguese, Polish, Spanish, and Japanese. That’s over 2,600 question files to keep in sync.

English is the source of truth. The community submits new questions only in English, but I want everyone to be able to practice in their own language. So I built a translation pipeline with GitHub Actions and GitHub Models that handles it all automatically.

ghcertified.com in Japanese — all content translated automatically

In this post I’ll walk you through how it works, from the single-file translation workflow all the way up to the 2096-file bulk retranslation.

Translating a Single File

Everything starts with translate-file.yml — a reusable workflow that translates one file to one language. Every other workflow in the system calls this one.

name: Translate a File

on:
  workflow_call:
    inputs:
      file:
        required: true
        type: string
      language_code:
        required: true
        type: string
      language_name:
        required: true
        type: string
      branch:
        required: false
        type: string
        default: 'i18n/update-translations'

The job has four steps:

  1. Checkout the repository
  2. Build the system prompt — inject the target language into a prompt template
  3. Translate — send the file to GitHub Models via prompt-action
  4. Save and commit — write the translated file to the correct language directory
- name: Translate file
  uses: FidelusAleksander/prompt-action@v1
  id: translate
  with:
    system-prompt: ${{ steps.build-system-prompt.outputs.updated-text }}
    prompt-file: ${{ inputs.file }}

- name: Save translation
  run: |
    FILE="${{ inputs.file }}"
    LANG="${{ inputs.language_code }}"
    TARGET_PATH="${FILE/questions\/en/questions\/$LANG}"
    mkdir -p "$(dirname "$TARGET_PATH")"
    echo "$TRANSLATED_TEXT" > "$TARGET_PATH"
  env:
    TRANSLATED_TEXT: ${{ steps.translate.outputs.text }}

The path substitution ${FILE/questions\/en/questions\/$LANG} maps questions/en/actions/question-001.md to questions/pt/actions/question-001.md.

One permission line makes it all work:

permissions:
  models: read

That single permission grants access to GitHub Models from within a workflow. No API keys and no external translation service to wire up.

Side note on reusable workflows: Right now, running my prompt-action from a reusable workflow is really the only way to fan out translations per file. GitHub Actions doesn’t support parallel steps within a single job yet. But that’s on the public roadmap for Q2 2026 — when it ships, it could change how I structure this entirely.

Diagram of the translate-file.yml reusable workflow

The prompt itself is pretty boring by design: preserve structure, translate the human-readable text, keep code and links intact, and keep GitHub terminology consistent. The {{ targetLanguage }} value gets injected at runtime using skills/action-text-variables, so one template covers all 4 target languages.

System prompt excerpt
You are a highly skilled {{ targetLanguage }} translator specializing
in technical documentation and GitHub certification content.

## Translation Rules

### Content Preservation
- Preserve the meaning, tone, formatting, structure, and style
- Keep frontmatter keys unchanged (translate only values)

### Technical Elements
- Keep technical terms, code blocks, and code examples in their original form
- Keep URLs, file paths, and image references unchanged

### GitHub Nomenclature
- GitHub, Git, Actions, Workflows, Copilot, CodeQL, Dependabot
- Pull Request, Repository, Commit, Branch, Fork, Issue
- GitHub Foundations, GitHub Actions, GitHub Advanced Security...

What a translated question looks like

Here’s a real question before and after translation to Portuguese:

English (source):

---
question: "Which statement is correct regarding passing permissions
  to reusable workflows?"
documentation: "https://docs.github.com/en/actions/..."
---

- [x] The `GITHUB_TOKEN` permissions passed from the caller workflow
  can be only downgraded by the called workflow.
- [ ] The `GITHUB_TOKEN` permissions passed from the caller workflow
  can be only elevated by the called workflow.

Portuguese (translated):

---
question: "Qual declaração está correta em relação ao repasse
  de permissões para workflows reutilizáveis?"
documentation: "https://docs.github.com/en/actions/..."
---

- [x] As permissões do `GITHUB_TOKEN` repassadas do workflow
  chamador só podem ser reduzidas pelo workflow chamado.
- [ ] As permissões do `GITHUB_TOKEN` repassadas do workflow
  chamador só podem ser elevadas pelo workflow chamado.

The documentation URL stays untouched, markdown structure is preserved, and inline code like GITHUB_TOKEN remains in English.

Auto-Translating on Every Push

With the single-file workflow in place, the next step was automating it. I wanted translations to happen automatically whenever English content changes on main.

auto-translate.yml handles this:

on:
  push:
    branches:
      - main
    paths:
      - "questions/en/**"

Someone merges a new community-contributed question? The pipeline kicks in.

The first job detects which files changed:

- name: Get changed files
  id: changed-files
  uses: tj-actions/changed-files@v47
  with:
    matrix: true
    files: |
      questions/en/**

The matrix: true flag outputs changed files as JSON, ready to feed into a matrix strategy. Then each changed file gets translated into all 4 target languages:

translate:
  needs: detect-changes
  strategy:
    max-parallel: 1
    matrix:
      language:
        - { code: "pt", name: "Portuguese" }
        - { code: "pl", name: "Polish" }
        - { code: "es", name: "Spanish" }
        - { code: "ja", name: "Japanese" }
      file: ${{ fromJSON(needs.detect-changes.outputs.changed_files) }}
  uses: ./.github/workflows/translate-file.yml
  with:
    file: ${{ matrix.file }}
    language_code: ${{ matrix.language.code }}
    language_name: ${{ matrix.language.name }}
    branch: i18n/auto-translate-${{ github.sha }}

If 3 files changed, that’s 3 × 4 = 12 translation jobs.

max-parallel: 1 is important here. Each job commits to the same branch, so running them in parallel would cause git push conflicts.

After all translations finish, a final job opens a pull request with the changes:

- name: Create Pull Request
  run: |
    gh pr create \
      --title "i18n: auto-translate content changes" \
      --body "Translations for content from commit ${{ github.sha }}." \
      --base main \
      --head i18n/auto-translate-${{ github.sha }}

Diagram of the auto-translate.yml pipeline showing push event, matrix of file × language jobs, and PR creation

GitHub Actions run showing the auto-translate pipeline with matrix jobs for 4 languages

Who Reviews the Translations?

Every translation lands in a pull request, never directly on main.

In a team setting, you could set up separate PRs per language and use CODEOWNERS to automatically assign native speakers as reviewers — a Portuguese speaker reviews questions/pt/**, a Japanese speaker reviews questions/ja/**, and so on.

I’m a one-person team on this project, so I just merge the PRs myself. Sometimes native speakers from the community come in and suggest improvements to the translations, which I welcome and merge as well.

Bulk Translating Everything

The auto-translate pipeline ran quietly for months, handling a few files here and there as the community contributed new questions. Then I migrated ghcertified.com to Next.js.

As part of the migration, I created mdquiz — a library for parsing markdown quiz files. The frontmatter structure and formatting of every question file changed. All 524 English questions got updated, which meant every existing translation was stale.

I needed to retranslate everything.

That’s what manual-bulk-translate.yml is for:

name: Manual Bulk Translate

on:
  workflow_dispatch:
    inputs:
      content_path:
        description: "Path to content directory to translate"
        default: "questions/en"
        type: string
      language_code:
        description: "Language code to translate to"
        type: choice
        options: [pt, pl, es, ja]
      language_name:
        description: "Language name"
        type: choice
        options: [Portuguese, Polish, Spanish, Japanese]

Triggered manually from the GitHub Actions UI — pick the content path and target language, hit go.

The prepare job collects every markdown file in the directory, then the matrix fans out — calling the same translate-file.yml for each one:

- name: Get all .md files
  id: get-files
  run: |
    files=$(find ${{ inputs.content_path }} -type f -name "*.md" \
      | jq -R -s -c 'split("\n")[:-1]')
    echo "files=$files" >> "$GITHUB_OUTPUT"

Diagram of the manual-bulk-translate.yml workflow showing dispatch inputs, file listing, and sequential translate jobs

There’s a catch though — GitHub Actions has a maximum of 256 jobs per matrix. With 524 questions across 5 exam categories, I couldn’t translate everything in a single run. I had to split it up by category and run multiple dispatches per language.

That meant a lot of clicking. 5 categories × 4 languages = 20 workflow dispatches. Not exactly one-click, but still vastly better than translating 2,096 files by hand.

Bulk translate workflow run showing 116 matrix jobs translating questions to Portuguese

The final result? PR #5082096 files changed, 13,496 additions, 16,220 deletions.

PR #508 showing 2096 files changed with bulk translations to 4 languages

Wrapping Up

What started as a single translate-file.yml grew into a system that keeps 2,600+ files across 5 languages in sync — automatically on every push, and manually when I need to redo everything.

Those 2096 files from the opening? A bunch of workflow dispatches, some patience, and zero hand-written translations.

If you want to build something similar, the ghcertified workflows are all open source. Check out prompt-action if you want to use GitHub Models in your own workflows.

And if you liked the diagrams in this post, I made them with Excalidraw MCP. The agent skill that made that workflow usable is covered in this post by Thomas Thornton.