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.3 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.
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
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
before_script:
- apk add --no-cache bash curl git unzip
script:
- curl -fsSL https://raw.githubusercontent.com/OnTheGoSystems/ptc-cli/v1.0.3/ptc-cli.sh -o ptc-cli.sh
- chmod +x ptc-cli.sh
- ./ptc-cli.sh --config-file .ptc-config.yml
- |
if ! git diff --quiet; then
git config user.email "ci@ptc"
git config user.name "PTC Translate"
git checkout -B ptc/translations
git add -A
git commit -m "chore(i18n): update translations via PTC [skip ci]"
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.3 to a different release if you want, and add a sha256sum check to get the same integrity guarantee the GitHub action gets from vendoring:
87efed00bd9345b9a4d5fb1972d8d53525246f6a2a4e6a48ae7d20d67e41362a 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.