Translate your source files with Private Translation Cloud (WPML) straight from CI — and get the results back as a self-updating pull request on every source push. PTC never touches your repo; the action runs the pinned ptc-cli in your pipeline and your own token opens the PR.
- GitHub: a composite action, used straight from this repository —
uses: OnTheGoSystems/ptc-action@v1 - GitLab: an inline job that
ptc initprints for you — see below
There is no GitHub Marketplace listing. uses: resolves against the repository, so the reference above works without one.
This action vendors ptc-cli v1.0.5 inside the action repo, so it never runs main at job time — the script that ships with a given action tag is the script that runs.
1. Get a token. Grab your PTC project token. A config is optional: with none, the action detects your layout at run time (see Inputs). To see and commit the detected layout up front, run ptc init in a checkout of your repo — it needs no token — and commit the .ptc-config.yml it writes.
2. Add secrets. Repo → Settings → Secrets and variables → Actions:
PTC_API_TOKEN— your PTC project token (required).PTC_PR_TOKEN— (recommended) a PAT or GitHub App token so the translation PR triggers your other CI checks. A bareGITHUB_TOKEN-opened PR does not trigger downstream workflows.
3. Add the workflow — .github/workflows/translate.yml:
name: Translate
on:
push:
branches: [main]
paths: ['locales/en.json'] # trigger only on SOURCE changes → loop-safe
workflow_dispatch: {}
permissions:
contents: write
pull-requests: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: OnTheGoSystems/ptc-action@v1
with:
api-token: ${{ secrets.PTC_API_TOKEN }}
create-pr: true
pr-token: ${{ secrets.PTC_PR_TOKEN }}api-token is the only input you have to pass. Add config-file: .ptc-config.yml only to point at a config somewhere other than the repository root — one committed at the root is picked up on its own.
⚠️ One-time setting forcreate-pr: enable Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests". This is the #1 silent first-run failure.
Self-hosted runners:
create-prrunspeter-evans/create-pull-requestv8, which needs Node 24 — Actions Runner v2.327.1 or later. GitHub-hosted runners already satisfy this.
create-prneeds a branch to open the pull request against, so it runs onpush,scheduleand aworkflow_dispatchfrom a branch. On apull_requestorpull_request_targetevent the run sits on GitHub's internal merge ref, and the translations it produces cover strings that exist in neither the base nor the head branch on its own — so the action fails the step with an explanation rather than open a pull request against a branch that never had them. The same applies topull_request_target(that event holds a write-scoped token while the workspace can contain a fork's files), tomerge_group(the queue branch is deleted when the queue resolves) and to a tag orrelease(a tag is not a base branch). Anything else running from a branch is accepted. Translate on a push to your source branch, or setcreate-pr: falseon those events.
The pull request is scoped to what the translation run wrote. The action records the working tree before it starts and stages only what appeared or changed afterwards, so a job that installs or builds before this step does not ship its lockfile or its
dist/under a translations title. If a run writes nothing, no pull request is opened. Two limits worth knowing: anything your job had alreadygit add-ed is part of the commit regardless (the commit takes the whole index), and a source file your job regenerates before this step is not part of it — if your pipeline extracts strings and then translates in the same job, commit the extracted source yourself, or the pull request will carry translations for strings the base branch does not have.
There is no GitLab component. include: component: is resolved by your own GitLab — the $CI_SERVER_FQDN in a component address is always your server — so a component we publish on one instance is unreachable from gitlab.com and from every self-hosted instance. Instead, ptc init prints a self-contained job you paste into .gitlab-ci.yml:
ptc-translate:
stage: deploy
image: alpine:3.22
# Loop-safe twice over: the job only runs on a push to the default branch (the
# translation push targets ptc/translations, so it cannot retrigger this job),
# and rules: below refuses a commit marked [skip translations].
rules:
# The second condition is what keeps this loop-safe, and it has to live
# here: GitLab evaluates rules: against the commit message, whereas
# `[skip ci]` in the message would suppress the pipeline of the merge
# request itself - leaving the translations untested and, with "Pipelines
# must succeed" enabled, unmergeable.
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_MESSAGE !~ /\[skip translations\]/'
before_script:
# jq is never invoked by the CLI. unzip is - it unpacks the downloaded
# translations; alpine already provides it as a busybox applet, so it is
# named here only to keep the job working if the image is ever changed.
# git is needed by the push step below, not by the CLI.
- apk add --no-cache bash curl git unzip
script:
# Downloaded OUTSIDE the checkout: anything this job writes into the working
# tree is a file the commit below could sweep into the merge request, and
# the CLI is 100+ KB of it.
- curl -fsSL https://raw.githubusercontent.com/OnTheGoSystems/ptc-cli/v1.0.5/ptc-cli.sh -o /tmp/ptc-cli.sh
- chmod +x /tmp/ptc-cli.sh
- rm -f /tmp/ptc-written
- /tmp/ptc-cli.sh --config-file .ptc-config.yml --written-manifest /tmp/ptc-written
# Pushing needs a token that may write to the repository. CI_JOB_TOKEN can,
# but ONLY if a maintainer turns on Settings > CI/CD > Job token permissions
# > "Allow Git push requests to the repository" (GitLab 18.4+, off by
# default). Otherwise set PTC_GIT_PUSH_TOKEN to a project access token with
# the write_repository scope, as a masked CI/CD variable.
# Staged from the manifest, so the merge request carries the translations and
# nothing else - not this job's downloads, not whatever an earlier step in
# your pipeline left in the working directory.
#
# `|| true` is not cosmetic: if a translation lands on a path your
# .gitignore covers, git exits 1 while still staging everything else, and
# GitLab would abort the job on that exit code alone.
#
# Staging comes BEFORE the check, and the check reads the index: on the
# first run the translations are new files, and a plain `git diff` only
# looks at tracked ones - it would report "nothing changed", skip the push,
# and leave a green job that produced no merge request.
- |
git config user.email "ci@ptc"
git config user.name "PTC Translate"
git checkout -B ptc/translations
git add --pathspec-from-file=/tmp/ptc-written --pathspec-file-nul || true
if ! git diff --cached --quiet; then
git commit -m "chore(i18n): update translations via PTC [skip translations]"
git push -o merge_request.create \
-o merge_request.target="$CI_DEFAULT_BRANCH" \
-o merge_request.title="Update translations from PTC" \
-f "https://gitlab-ci-token:${PTC_GIT_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:ptc/translations
fiStore PTC_API_TOKEN as a masked CI/CD variable (Settings → CI/CD → Variables). It is read from the environment, never placed on the command line.
On the before_script line: bash and curl are what a bare alpine:3.22 lacks, git is for the push step at the end of the job, and unzip unpacks the downloaded translations — alpine already provides it as a busybox applet, so it is named only to survive an image swap. jq used to be on that line and is never invoked.
The push needs a token that may write to the repository. CI_JOB_TOKEN can, but only if a maintainer enables Settings → CI/CD → Job token permissions → "Allow Git push requests to the repository" (GitLab 18.4+, off by default). Otherwise set PTC_GIT_PUSH_TOKEN to a project access token with the write_repository scope, also masked.
Loop-safe twice over: the job only runs on a push to the default branch — the translation push targets ptc/translations, so it cannot re-trigger — and the commit carries [skip ci], the only skip token GitLab honours.
Pin v1.0.5 to a different release if you want, and add a sha256sum check to get the same integrity guarantee the GitHub action gets from vendoring:
d5b1b2c62c530f43d76aa924e399548379369e458955a31bd11539580d71428d ptc-cli.sh
Running it as a component on your own instance
templates/translate/template.yml in this repository is the component source, kept for anyone who wants to mirror it into their own GitLab and include it from there — where $CI_SERVER_FQDN finally is your server, so the address resolves. Copy the repository to your instance, publish it to your CI/CD Catalog, and include it under your own address. We publish it nowhere, and nothing PTC prints points at it.
| Input | Required | Default | Description |
|---|---|---|---|
api-token |
✅ | — | PTC project token. Passed via the PTC_API_TOKEN env var, never argv. |
config-file |
'' |
Path to .ptc-config.yml. Optional: a .ptc-config.yml committed at the repo root is used on its own, and with no config at all the action detects the layout itself. Takes precedence over source-locale/patterns. |
|
source-locale |
'' |
Source language code. Only to override detection (with patterns). |
|
patterns |
'' |
Glob(s) with a {{lang}} slot. Only to override detection. |
|
file-tag-name |
auto | PTC file tag (defaults to the git branch). | |
api-url |
https://app.ptc.wpml.org/api/v1/ |
Override for a self-hosted instance. | |
project-dir |
. |
Directory treated as project root. | |
create-pr |
false |
Open/update a PR with the translations. | |
pr-token |
'' → falls back to github.token |
Token that opens the PR (use a PAT/App token to trigger downstream CI). | |
pr-branch |
ptc/translations |
Stable branch — re-runs update the same PR. |
| Output | Description |
|---|---|
pr-number |
The PR number (when create-pr=true and there were changes). |
pr-url |
The PR URL. |
- Trigger on source paths only (
paths:/ default-branch rule) — a translation-only commit can never re-trigger the run. This is what actually breaks the loop. - Stable
ptc/translationsbranch — re-runs update ONE PR instead of spawning new ones, and a PR branch is not a trigger branch. - PR token is explicit so the translation PR actually runs your repo's own checks.
The translation commit carries a
[skip translations]marker. It is a human-readable label, not a CI skip token — GitHub honours only[skip ci],[ci skip],[no ci],[skip actions]and[actions skip]. Do not rely on it as a guard; rely on the two above.
- The PTC token is read from an env var and
::add-mask::ed — it never appears inargvor logs. - Every third-party dependency is pinned to a full commit SHA, not a movable tag (
peter-evans/create-pull-requestv8.1.1); pin the action itself to a full SHA too if your org requires it. ptc-cliis vendored in this repo, not downloaded at job time — there is no runtime fetch to intercept.- The GitLab job pins its base image (
alpine:3.22) and itsptc-clirelease tag; add thesha256sumcheck above for full parity.