From 85450039222e382d465582f5f5026ef769b6c4fe Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 11:22:23 +0200 Subject: [PATCH 01/17] fix(i18n): unmask pipeline token authentication (CEL-1259) --- .github/workflows/i18n-pipeline.yaml | 34 +++++++++++++++------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 676ca20..9f1557d 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -126,6 +126,11 @@ jobs: steps: - uses: actions/checkout@v4 + with: + # Create PR must authenticate both its push and REST call with the + # same token. Otherwise checkout's persisted github.token can make + # the push succeed while a stale PAT fails seconds later. + persist-credentials: false # CEL-384 prong 1b: catch the most-common failure mode — caller did not # forward NPM_TOKEN but the repo has private @cellarnode/* deps — and @@ -280,9 +285,10 @@ jobs: git checkout -b "$BRANCH" git add "$LOCALES_PATH/" .polyglot-cache.json src/ || true git commit -m "chore(i18n): sync + translate" - # Push with GH_TOKEN (not the checkout-persisted github.token) so the - # branch and the PR share the same author identity — required for the - # PR's `on: pull_request` CI to fire when the PAT is configured. + # Checkout persists no credentials, so this URL's GH_TOKEN is the + # only credential available to the push. The branch and PR therefore + # share one author identity, which lets `on: pull_request` CI fire + # when the PAT is configured. git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" cat > "$RUNNER_TEMP/i18n-pr-body.md" <<'PR_BODY_EOF' Automated i18n pipeline: synced locale files, translated via Gemini, regenerated types. @@ -294,12 +300,11 @@ jobs: backlog would trip GitHub's secondary rate limit on content creation, so the supersession notice lives here instead. PR_BODY_EOF - # REST, not `gh pr create`: fine-grained PATs cannot authenticate - # against api.github.com/graphql (observed live: HTTP 401 "Bad - # credentials (https://api.github.com/graphql)" from a valid PAT - # whose git push succeeded seconds earlier, run 33106558320). - # `gh pr create` touches GraphQL; `gh api` POST /pulls is pure REST - # and works with fine-grained PATs. + # REST, not `gh pr create`: this keeps the token contract limited to + # repository Contents and Pull requests permissions. Run 33157661915 + # proved why checkout credentials must be disabled: github.token + # authenticated the push while a shadowed stale PAT failed this REST + # call with HTTP 401. PR_NUMBER=$(gh api "repos/${GITHUB_REPOSITORY}/pulls" \ -f title="chore(i18n): sync + translate" \ -F body=@"$RUNNER_TEMP/i18n-pr-body.md" \ @@ -392,13 +397,10 @@ jobs: if: success() && steps.diff.outputs.changed == 'true' && steps.create_pr.outputs.pr_number != '' continue-on-error: true env: - # The CALL uses github.token, not the PAT: `gh pr merge --auto` is a - # GraphQL mutation (enablePullRequestAutoMerge) and fine-grained PATs - # cannot authenticate against the GraphQL API (the same 401 that - # broke `gh pr create`, run 33106558320). github.token is - # GraphQL-capable and enabling auto-merge on someone else's PR only - # needs repo write. PAT_CONFIGURED still gates the step: a - # github.token-AUTHORED PR gets no CI, so arming auto-merge on one + # The call uses github.token because auto-merge is a GraphQL mutation; + # the PAT contract stops at git push and REST PR creation. + # PAT_CONFIGURED prevents auto-merge for a github.token-authored PR, + # which gets no CI, so arming auto-merge on one # would either hang forever or (no required checks) merge ungated. GH_TOKEN: ${{ github.token }} PAT_CONFIGURED: ${{ secrets.I18N_PIPELINE_TOKEN != '' }} From 689b358882ccb9086ad9e93a3b9cd59f295319cc Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 11:32:49 +0200 Subject: [PATCH 02/17] fix(i18n): require secure pipeline PAT auth (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 26 ++++++++++++ .github/workflows/i18n-pipeline.yaml | 54 ++++++++++-------------- 2 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 .github/tests/i18n-pipeline-auth.test.rb diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb new file mode 100644 index 0000000..e9cfa43 --- /dev/null +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -0,0 +1,26 @@ +require "yaml" + +workflow_path = File.expand_path("../workflows/i18n-pipeline.yaml", __dir__) +workflow = YAML.safe_load(File.read(workflow_path), aliases: true) + +pipeline_token = workflow + .fetch("on") { workflow.fetch(true) } + .fetch("workflow_call") + .fetch("secrets") + .fetch("I18N_PIPELINE_TOKEN") +abort "I18N pipeline token must be required" unless pipeline_token.fetch("required") == true + +steps = workflow.fetch("jobs").fetch("sync-translate").fetch("steps") +checkout = steps.find { |step| step.fetch("uses", "").start_with?("actions/checkout@") } +abort "Checkout must disable persisted credentials" unless checkout&.fetch("with", {})&.fetch("persist-credentials", nil) == false + +create_pr = steps.find { |step| step.fetch("id", "") == "create_pr" } +abort "Create PR must use only I18N_PIPELINE_TOKEN" unless create_pr&.fetch("env", {})&.fetch("GH_TOKEN", nil) == "${{ secrets.I18N_PIPELINE_TOKEN }}" + +script = create_pr.fetch("run") +abort "Push must not place GH_TOKEN in its URL" if script.include?('x-access-token:${GH_TOKEN}@') +abort "Push must use an ephemeral credential file" unless script.include?('GIT_CREDENTIAL_FILE="$RUNNER_TEMP/i18n-git-credentials"') +abort "Credential file must be removed on exit" unless script.include?('trap \'rm -f "$GIT_CREDENTIAL_FILE"\' EXIT') +abort "Push must use the credential-free repository URL" unless script.include?('git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"') + +puts "i18n pipeline authentication boundary: PASS" diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 9f1557d..69d7be6 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -88,13 +88,11 @@ on: I18N_PIPELINE_TOKEN: description: >- Fine-grained PAT (Contents RW + Pull requests RW on the consumer - repos) used to author/push the i18n PR so its `on: pull_request` CI - fires, and to arm auto-merge (CEL-1259 steps 2-3). Optional — - callers on `secrets: inherit` forward it automatically once the org - secret exists; explicit-map callers must forward it by name. Absent, - the pipeline falls back to github.token (PR opens, CI parked, no - auto-merge). - required: false + repos) used to push the generated branch and create its PR through + REST so `on: pull_request` CI fires. Callers on `secrets: inherit` + forward the organization secret automatically; explicit-map callers + must forward it by name. + required: true jobs: sync-translate: @@ -269,27 +267,28 @@ jobs: id: create_pr if: steps.diff.outputs.changed == 'true' env: - # CEL-1259 step 2/3 — prefer the org's I18N_PIPELINE_TOKEN (fine- - # grained PAT forwarded via `secrets: inherit`, the CEL-361 caller - # contract). PRs authored (and branches pushed) with github.token - # never trigger `on: pull_request`, so their CI parks at - # action_required forever. Falls back to github.token so the - # pipeline keeps working (with parked CI) until the secret exists. - GH_TOKEN: ${{ secrets.I18N_PIPELINE_TOKEN || github.token }} + # CEL-1259 step 2/3 — the organization PAT is the sole credential for + # branch push and REST PR creation. github.token-authored PRs never + # trigger `on: pull_request`, so falling back would park CI at + # action_required forever. + GH_TOKEN: ${{ secrets.I18N_PIPELINE_TOKEN }} LOCALES_PATH: ${{ inputs.locales_path }} run: | set -euo pipefail BRANCH="chore/i18n-pipeline-$(date +%Y%m%d-%H%M%S)" + GIT_CREDENTIAL_FILE="$RUNNER_TEMP/i18n-git-credentials" + trap 'rm -f "$GIT_CREDENTIAL_FILE"' EXIT git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" git add "$LOCALES_PATH/" .polyglot-cache.json src/ || true git commit -m "chore(i18n): sync + translate" - # Checkout persists no credentials, so this URL's GH_TOKEN is the - # only credential available to the push. The branch and PR therefore - # share one author identity, which lets `on: pull_request` CI fire - # when the PAT is configured. - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + # Keep the PAT out of process arguments. The ephemeral credential + # file is runner-local, mode 0600, and removed by the EXIT trap. + printf 'https://x-access-token:%s@github.com\n' "$GH_TOKEN" > "$GIT_CREDENTIAL_FILE" + chmod 600 "$GIT_CREDENTIAL_FILE" + git config --local credential.helper "store --file=$GIT_CREDENTIAL_FILE" + git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" cat > "$RUNNER_TEMP/i18n-pr-body.md" <<'PR_BODY_EOF' Automated i18n pipeline: synced locale files, translated via Gemini, regenerated types. @@ -389,28 +388,19 @@ jobs: # CEL-1259 step 3/3 — flip auto-merge on the fresh PR so it lands the # moment its required checks go green. Only meaningful when the PR was # authored with I18N_PIPELINE_TOKEN (step 2): a github.token PR never - # gets CI, so auto-merge would either wait forever or — worse, on a repo - # with no required checks — merge translations with zero gates. The - # in-script guard therefore skips silently until the secret exists. + # gets CI, so the reusable workflow requires the PAT at launch. # `allow_auto_merge` is enabled on all three consumer repos (2026-08-27). - name: Enable auto-merge on the new PR if: success() && steps.diff.outputs.changed == 'true' && steps.create_pr.outputs.pr_number != '' continue-on-error: true env: - # The call uses github.token because auto-merge is a GraphQL mutation; - # the PAT contract stops at git push and REST PR creation. - # PAT_CONFIGURED prevents auto-merge for a github.token-authored PR, - # which gets no CI, so arming auto-merge on one - # would either hang forever or (no required checks) merge ungated. + # The auto-merge step uses github.token for branch-rule REST probes + # and the GraphQL mutation. The PAT contract stops at branch push and + # REST PR creation. GH_TOKEN: ${{ github.token }} - PAT_CONFIGURED: ${{ secrets.I18N_PIPELINE_TOKEN != '' }} NEW_PR_NUMBER: ${{ steps.create_pr.outputs.pr_number }} run: | set -u - if [ "${PAT_CONFIGURED}" != "true" ]; then - echo "I18N_PIPELINE_TOKEN not configured — skipping auto-merge (CEL-1259 step 2 pending)." - exit 0 - fi # Refuse to arm auto-merge unless the base branch has required # status checks. With none, GitHub either rejects the mutation (the # PR is immediately mergeable) or — in the brief window while From d224228671c6eb172c09d4763fa8ebd6df0b83d7 Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 11:43:12 +0200 Subject: [PATCH 03/17] fix(i18n): isolate translation caches per language (CEL-1259) --- .../i18n-pipeline-translation-cache.test.rb | 21 +++++++ .github/workflows/i18n-pipeline.yaml | 57 +++++++++++++++---- 2 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 .github/tests/i18n-pipeline-translation-cache.test.rb diff --git a/.github/tests/i18n-pipeline-translation-cache.test.rb b/.github/tests/i18n-pipeline-translation-cache.test.rb new file mode 100644 index 0000000..4f6b4e5 --- /dev/null +++ b/.github/tests/i18n-pipeline-translation-cache.test.rb @@ -0,0 +1,21 @@ +require "yaml" + +workflow_path = File.expand_path("../workflows/i18n-pipeline.yaml", __dir__) +workflow = YAML.safe_load(File.read(workflow_path), aliases: true) +steps = workflow.fetch("jobs").fetch("sync-translate").fetch("steps") +translate = steps.find { |step| step.fetch("name", "") == "Translate new/changed keys" } +abort "Translation step missing" unless translate + +env = translate.fetch("env", {}) +abort "Target languages must cross into the shell as data" unless env.fetch("TARGET_LANGUAGES", nil) == "${{ inputs.target_languages }}" +abort "Translation context must cross into the shell as data" unless env.fetch("TRANSLATION_CONTEXT", nil) == "${{ inputs.context }}" + +script = translate.fetch("run") +abort "Translation must snapshot the original cache" unless script.include?('cp .polyglot-cache.json "$BASE_CACHE"') +abort "Translation must process one target language at a time" unless script.include?('for language in "${languages[@]}"; do') +abort "Every language must start from the original cache" unless script.include?('cp "$BASE_CACHE" "$language_cache"') +abort "CLI must receive one target language per invocation" unless script.include?('--output-languages "$language"') +abort "Each language must receive its own cache copy" unless script.include?('--cache-file "$language_cache"') +abort "Updated source hashes must return to the tracked cache" unless script.include?('cp "$updated_cache" .polyglot-cache.json') + +puts "i18n pipeline per-language cache isolation: PASS" diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 69d7be6..94268a3 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -187,22 +187,59 @@ jobs: env: GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} FORCE_TRANSLATE: ${{ inputs.force-translate }} + LOCALES_PATH: ${{ inputs.locales_path }} + TARGET_LANGUAGES: ${{ inputs.target_languages }} + TRANSLATION_CONTEXT: ${{ inputs.context }} run: | - FORCE_FLAG="" - if [ "$FORCE_TRANSLATE" = "true" ]; then - FORCE_FLAG="--force" + set -euo pipefail + BASE_CACHE="$RUNNER_TEMP/polyglot-cache-base.json" + if [ -f .polyglot-cache.json ]; then + cp .polyglot-cache.json "$BASE_CACHE" + else + printf '{}\n' > "$BASE_CACHE" fi # CEL-449 — `pnpm exec` resolves to the consumer's local # `polyglot-i18n` devDep (lockfile-pinned). Was a bare # invocation reading from the global install before CEL-449. - pnpm exec polyglot-i18n translate \ - --input "./${{ inputs.locales_path }}/en" \ - --output-languages "${{ inputs.target_languages }}" \ - --provider gemini \ - --api-key "$GOOGLE_API_KEY" \ - --context "${{ inputs.context }}" \ - $FORCE_FLAG + # polyglot-i18n 0.2.x stores source hashes in one cache shared by all + # target languages. A multi-language invocation updates that cache + # after its first language, causing later languages to skip changed + # keys they already contain. Give every language the same original + # cache snapshot, then keep one equivalent updated copy. + IFS=',' read -r -a languages <<< "$TARGET_LANGUAGES" + updated_cache="" + index=0 + for language in "${languages[@]}"; do + language="$(printf '%s' "$language" | tr -d '[:space:]')" + if [[ ! "$language" =~ ^[a-z]{2}(-[A-Za-z]{2,})?$ ]]; then + echo "::error::Invalid target language code: $language" + exit 1 + fi + + language_cache="$RUNNER_TEMP/polyglot-cache-$index.json" + cp "$BASE_CACHE" "$language_cache" + translate_args=( + --input "./$LOCALES_PATH/en" + --output-languages "$language" + --provider gemini + --api-key "$GOOGLE_API_KEY" + --context "$TRANSLATION_CONTEXT" + --cache-file "$language_cache" + ) + if [ "$FORCE_TRANSLATE" = "true" ]; then + translate_args+=(--force) + fi + pnpm exec polyglot-i18n translate "${translate_args[@]}" + updated_cache="$language_cache" + index=$((index + 1)) + done + + if [ -z "$updated_cache" ]; then + echo "::error::At least one target language is required" + exit 1 + fi + cp "$updated_cache" .polyglot-cache.json # CEL-434 — `|| true` REMOVED. Previously this step silently swallowed # failures. When `i18next-cli types` failed (e.g., because the consuming From 046b38d3c1a70cbeb37a17bd0466ef6454f60a5a Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 12:06:03 +0200 Subject: [PATCH 04/17] fix(i18n): fail closed on ungated auto-merge (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 14 +++++++++++- .github/workflows/i18n-pipeline.yaml | 29 ++++++++++++++++++------ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index e9cfa43..4607d89 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -19,8 +19,20 @@ script = create_pr.fetch("run") abort "Push must not place GH_TOKEN in its URL" if script.include?('x-access-token:${GH_TOKEN}@') -abort "Push must use an ephemeral credential file" unless script.include?('GIT_CREDENTIAL_FILE="$RUNNER_TEMP/i18n-git-credentials"') +abort "Credential file creation must use a restrictive umask" unless script.include?("umask 077") +abort "Push must use an atomic ephemeral credential file" unless script.include?('GIT_CREDENTIAL_FILE=$(mktemp "$RUNNER_TEMP/i18n-git-credentials.XXXXXX")') abort "Credential file must be removed on exit" unless script.include?('trap \'rm -f "$GIT_CREDENTIAL_FILE"\' EXIT') abort "Push must use the credential-free repository URL" unless script.include?('git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"') +push_index = script.index('git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"') +unset_index = script.index("git config --local --unset-all credential.helper") +remove_index = script.index('rm -f "$GIT_CREDENTIAL_FILE"', push_index) +abort "Credential helper must be unset immediately after push" unless unset_index && unset_index > push_index +abort "Credential file must be removed immediately after push" unless remove_index && remove_index > unset_index + +auto_merge = steps.find { |step| step.fetch("name", "") == "Enable auto-merge on the new PR" } +auto_merge_script = auto_merge.fetch("run") +abort "Branch-rule API failures must not append response JSON to zero" if auto_merge_script.include?("|| echo 0") +abort "Ruleset count must default before the API probe" unless auto_merge_script.include?("RULES=0") +abort "Classic protection count must default before the API probe" unless auto_merge_script.include?("CLASSIC=0") puts "i18n pipeline authentication boundary: PASS" diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 94268a3..861301c 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -313,7 +313,8 @@ jobs: run: | set -euo pipefail BRANCH="chore/i18n-pipeline-$(date +%Y%m%d-%H%M%S)" - GIT_CREDENTIAL_FILE="$RUNNER_TEMP/i18n-git-credentials" + umask 077 + GIT_CREDENTIAL_FILE=$(mktemp "$RUNNER_TEMP/i18n-git-credentials.XXXXXX") trap 'rm -f "$GIT_CREDENTIAL_FILE"' EXIT git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -321,11 +322,13 @@ jobs: git add "$LOCALES_PATH/" .polyglot-cache.json src/ || true git commit -m "chore(i18n): sync + translate" # Keep the PAT out of process arguments. The ephemeral credential - # file is runner-local, mode 0600, and removed by the EXIT trap. + # file is created atomically as mode 0600 and removed immediately + # after the push; the EXIT trap remains as failure-path cleanup. printf 'https://x-access-token:%s@github.com\n' "$GH_TOKEN" > "$GIT_CREDENTIAL_FILE" - chmod 600 "$GIT_CREDENTIAL_FILE" git config --local credential.helper "store --file=$GIT_CREDENTIAL_FILE" git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + git config --local --unset-all credential.helper + rm -f "$GIT_CREDENTIAL_FILE" cat > "$RUNNER_TEMP/i18n-pr-body.md" <<'PR_BODY_EOF' Automated i18n pipeline: synced locale files, translated via Gemini, regenerated types. @@ -443,10 +446,22 @@ jobs: # PR is immediately mergeable) or — in the brief window while # mergeability is still UNKNOWN — merges instantly with zero gates, # and that push triggers the consumers' production deploy workflows. - RULES=$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${GITHUB_REF_NAME}" \ - --jq '[.[] | select(.type == "required_status_checks")] | length' 2>/dev/null || echo 0) - CLASSIC=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${GITHUB_REF_NAME}/protection/required_status_checks" \ - --jq '.checks | length' 2>/dev/null || echo 0) + RULES=0 + if RULES_QUERY=$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${GITHUB_REF_NAME}" \ + --jq '[.[] | select(.type == "required_status_checks")] | length' 2>/dev/null); then + case "$RULES_QUERY" in + ''|*[!0-9]*) ;; + *) RULES="$RULES_QUERY" ;; + esac + fi + CLASSIC=0 + if CLASSIC_QUERY=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${GITHUB_REF_NAME}/protection/required_status_checks" \ + --jq '.checks | length' 2>/dev/null); then + case "$CLASSIC_QUERY" in + ''|*[!0-9]*) ;; + *) CLASSIC="$CLASSIC_QUERY" ;; + esac + fi if [ "${RULES:-0}" = "0" ] && [ "${CLASSIC:-0}" = "0" ]; then echo "::warning::${GITHUB_REF_NAME} has no required status checks — refusing to arm auto-merge (it would merge ungated and deploy). Add branch protection or a ruleset requiring CI, then auto-merge activates on the next run." exit 0 From d928d842c7f0dfb2a0efb724889649d938d453a7 Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 12:10:44 +0200 Subject: [PATCH 05/17] fix(i18n): clean credentials on push failure (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 11 ++++++----- .github/workflows/i18n-pipeline.yaml | 13 ++++++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index 4607d89..e387ea0 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -21,13 +21,14 @@ abort "Push must not place GH_TOKEN in its URL" if script.include?('x-access-token:${GH_TOKEN}@') abort "Credential file creation must use a restrictive umask" unless script.include?("umask 077") abort "Push must use an atomic ephemeral credential file" unless script.include?('GIT_CREDENTIAL_FILE=$(mktemp "$RUNNER_TEMP/i18n-git-credentials.XXXXXX")') -abort "Credential file must be removed on exit" unless script.include?('trap \'rm -f "$GIT_CREDENTIAL_FILE"\' EXIT') +abort "Credential cleanup must cover failure paths" unless script.include?("trap cleanup_git_credentials EXIT") +abort "Failure cleanup must unset the helper" unless script.include?("git config --local --unset-all credential.helper >/dev/null 2>&1 || true") abort "Push must use the credential-free repository URL" unless script.include?('git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"') push_index = script.index('git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"') -unset_index = script.index("git config --local --unset-all credential.helper") -remove_index = script.index('rm -f "$GIT_CREDENTIAL_FILE"', push_index) -abort "Credential helper must be unset immediately after push" unless unset_index && unset_index > push_index -abort "Credential file must be removed immediately after push" unless remove_index && remove_index > unset_index +cleanup_index = script.index("cleanup_git_credentials\n", push_index) +clear_trap_index = script.index("trap - EXIT", push_index) +abort "Credentials must be cleaned immediately after push" unless cleanup_index && cleanup_index > push_index +abort "Credential cleanup trap must be cleared after success" unless clear_trap_index && clear_trap_index > cleanup_index auto_merge = steps.find { |step| step.fetch("name", "") == "Enable auto-merge on the new PR" } auto_merge_script = auto_merge.fetch("run") diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 861301c..4628914 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -314,8 +314,15 @@ jobs: set -euo pipefail BRANCH="chore/i18n-pipeline-$(date +%Y%m%d-%H%M%S)" umask 077 + GIT_CREDENTIAL_FILE="" + cleanup_git_credentials() { + git config --local --unset-all credential.helper >/dev/null 2>&1 || true + if [ -n "$GIT_CREDENTIAL_FILE" ]; then + rm -f "$GIT_CREDENTIAL_FILE" + fi + } + trap cleanup_git_credentials EXIT GIT_CREDENTIAL_FILE=$(mktemp "$RUNNER_TEMP/i18n-git-credentials.XXXXXX") - trap 'rm -f "$GIT_CREDENTIAL_FILE"' EXIT git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" @@ -327,8 +334,8 @@ jobs: printf 'https://x-access-token:%s@github.com\n' "$GH_TOKEN" > "$GIT_CREDENTIAL_FILE" git config --local credential.helper "store --file=$GIT_CREDENTIAL_FILE" git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" - git config --local --unset-all credential.helper - rm -f "$GIT_CREDENTIAL_FILE" + cleanup_git_credentials + trap - EXIT cat > "$RUNNER_TEMP/i18n-pr-body.md" <<'PR_BODY_EOF' Automated i18n pipeline: synced locale files, translated via Gemini, regenerated types. From 7ab773ba56d7f23f26a6f8392031d2904fcd2e82 Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 12:14:13 +0200 Subject: [PATCH 06/17] fix(i18n): accept regional language tags (CEL-1259) --- .github/workflows/i18n-pipeline.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 4628914..7447fe9 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -212,8 +212,8 @@ jobs: index=0 for language in "${languages[@]}"; do language="$(printf '%s' "$language" | tr -d '[:space:]')" - if [[ ! "$language" =~ ^[a-z]{2}(-[A-Za-z]{2,})?$ ]]; then - echo "::error::Invalid target language code: $language" + if [ -z "$language" ]; then + echo "::error::Target language code must not be empty" exit 1 fi From d8d918662a4a986eae6b8c5659985e1fdd9c4615 Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 12:20:43 +0200 Subject: [PATCH 07/17] ci(i18n): run pipeline contracts (CEL-1259) --- .github/workflows/validate-i18n-pipeline.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/validate-i18n-pipeline.yaml diff --git a/.github/workflows/validate-i18n-pipeline.yaml b/.github/workflows/validate-i18n-pipeline.yaml new file mode 100644 index 0000000..c8cd93b --- /dev/null +++ b/.github/workflows/validate-i18n-pipeline.yaml @@ -0,0 +1,30 @@ +name: Validate i18n pipeline + +on: + pull_request: + paths: + - .github/workflows/i18n-pipeline.yaml + - .github/tests/i18n-pipeline-*.test.rb + - .github/workflows/validate-i18n-pipeline.yaml + push: + branches: + - main + paths: + - .github/workflows/i18n-pipeline.yaml + - .github/tests/i18n-pipeline-*.test.rb + - .github/workflows/validate-i18n-pipeline.yaml + +permissions: + contents: read + +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate authentication boundaries + run: ruby .github/tests/i18n-pipeline-auth.test.rb + + - name: Validate translation cache isolation + run: ruby .github/tests/i18n-pipeline-translation-cache.test.rb From cdb65f39301ed4092fd8f358e788bf1c5e536d4a Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 12:26:11 +0200 Subject: [PATCH 08/17] ci(i18n): harden contract checkout (CEL-1259) --- .github/workflows/validate-i18n-pipeline.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-i18n-pipeline.yaml b/.github/workflows/validate-i18n-pipeline.yaml index c8cd93b..9d9191e 100644 --- a/.github/workflows/validate-i18n-pipeline.yaml +++ b/.github/workflows/validate-i18n-pipeline.yaml @@ -21,7 +21,9 @@ jobs: contracts: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false - name: Validate authentication boundaries run: ruby .github/tests/i18n-pipeline-auth.test.rb From aad9586cba36bd365eaa9f612b0a4d3cb26928da Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 12:30:23 +0200 Subject: [PATCH 09/17] ci(i18n): pin pipeline checkout (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 3 ++- .github/workflows/i18n-pipeline.yaml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index e387ea0..c73bf8a 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -11,7 +11,8 @@ abort "I18N pipeline token must be required" unless pipeline_token.fetch("required") == true steps = workflow.fetch("jobs").fetch("sync-translate").fetch("steps") -checkout = steps.find { |step| step.fetch("uses", "").start_with?("actions/checkout@") } +checkout = steps.find { |step| step.fetch("uses", "") == "actions/checkout@11d5960a326750d5838078e36cf38b85af677262" } +abort "Checkout must use the audited v4.4.0 commit" unless checkout abort "Checkout must disable persisted credentials" unless checkout&.fetch("with", {})&.fetch("persist-credentials", nil) == false create_pr = steps.find { |step| step.fetch("id", "") == "create_pr" } diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 7447fe9..15ea690 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -123,7 +123,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: # Create PR must authenticate both its push and REST call with the # same token. Otherwise checkout's persisted github.token can make From d880829350d0dd80c05a8aac5a92eb7e9e5f4e2e Mon Sep 17 00:00:00 2001 From: mjnong Date: Fri, 28 Aug 2026 12:32:04 +0200 Subject: [PATCH 10/17] ci(i18n): pin privileged actions (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 2 ++ .github/workflows/i18n-pipeline.yaml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index c73bf8a..5db27e2 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -14,6 +14,8 @@ checkout = steps.find { |step| step.fetch("uses", "") == "actions/checkout@11d5960a326750d5838078e36cf38b85af677262" } abort "Checkout must use the audited v4.4.0 commit" unless checkout abort "Checkout must disable persisted credentials" unless checkout&.fetch("with", {})&.fetch("persist-credentials", nil) == false +abort "setup-node must use an audited commit" unless steps.any? { |step| step.fetch("uses", "") == "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020" } +abort "pnpm setup must use an audited commit" unless steps.any? { |step| step.fetch("uses", "") == "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1" } create_pr = steps.find { |step| step.fetch("id", "") == "create_pr" } abort "Create PR must use only I18N_PIPELINE_TOKEN" unless create_pr&.fetch("env", {})&.fetch("GH_TOKEN", nil) == "${{ secrets.I18N_PIPELINE_TOKEN }}" diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 15ea690..6ea4dbf 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -159,12 +159,12 @@ jobs: fi - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: ${{ inputs.node_version }} - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - name: Install project dependencies run: pnpm install --frozen-lockfile From 5ee593f668d8b296da722cc203603e04c9ae9dee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 12:37:55 +0200 Subject: [PATCH 11/17] fix(i18n): validate language cache inputs (CEL-1259) --- .github/tests/i18n-pipeline-translation-cache.test.rb | 2 ++ .github/workflows/i18n-pipeline.yaml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/tests/i18n-pipeline-translation-cache.test.rb b/.github/tests/i18n-pipeline-translation-cache.test.rb index 4f6b4e5..ace6623 100644 --- a/.github/tests/i18n-pipeline-translation-cache.test.rb +++ b/.github/tests/i18n-pipeline-translation-cache.test.rb @@ -12,6 +12,8 @@ script = translate.fetch("run") abort "Translation must snapshot the original cache" unless script.include?('cp .polyglot-cache.json "$BASE_CACHE"') +abort "Translation must initialize an empty cache" unless script.include?(%q{printf '{}\n' > "$BASE_CACHE"}) +abort "Translation must reject a trailing language delimiter" unless script.include?('if [[ "$TARGET_LANGUAGES" == *, ]]; then') abort "Translation must process one target language at a time" unless script.include?('for language in "${languages[@]}"; do') abort "Every language must start from the original cache" unless script.include?('cp "$BASE_CACHE" "$language_cache"') abort "CLI must receive one target language per invocation" unless script.include?('--output-languages "$language"') diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 6ea4dbf..8eb84e7 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -207,6 +207,10 @@ jobs: # after its first language, causing later languages to skip changed # keys they already contain. Give every language the same original # cache snapshot, then keep one equivalent updated copy. + if [[ "$TARGET_LANGUAGES" == *, ]]; then + echo "::error::Target language code must not be empty" + exit 1 + fi IFS=',' read -r -a languages <<< "$TARGET_LANGUAGES" updated_cache="" index=0 From f0924cd03a54a2b0b46e4ecd346446dcb715fcaa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 12:48:17 +0200 Subject: [PATCH 12/17] fix(i18n): count configured ruleset checks (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 17 +++++++++++++++++ .github/workflows/i18n-pipeline.yaml | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index 5db27e2..785eb3a 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -1,3 +1,4 @@ +require "open3" require "yaml" workflow_path = File.expand_path("../workflows/i18n-pipeline.yaml", __dir__) @@ -39,4 +40,20 @@ abort "Ruleset count must default before the API probe" unless auto_merge_script.include?("RULES=0") abort "Classic protection count must default before the API probe" unless auto_merge_script.include?("CLASSIC=0") +ruleset_check_filter = '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]? | select((.context? | type) == "string" and (.context | length) > 0)] | length' +abort "Ruleset probe must count configured checks, not rule objects" unless auto_merge_script.include?("--jq '#{ruleset_check_filter}'") + +ruleset_fixtures = { + "no rules" => ["[]", "0"], + "empty required-check rule" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[]}}]', "0"], + "malformed required check" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{}]}}]', "0"], + "one configured check" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"}]}}]', "1"], + "two rules with three checks" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"},{"context":"test"}]}},{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"build"}]}}]', "3"], +}.freeze + +ruleset_fixtures.each do |name, (payload, expected)| + output, status = Open3.capture2e("jq", "-r", ruleset_check_filter, stdin_data: payload) + abort "Ruleset fixture #{name} failed: #{output}" unless status.success? && output.strip == expected +end + puts "i18n pipeline authentication boundary: PASS" diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 8eb84e7..e1367c3 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -459,7 +459,7 @@ jobs: # and that push triggers the consumers' production deploy workflows. RULES=0 if RULES_QUERY=$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${GITHUB_REF_NAME}" \ - --jq '[.[] | select(.type == "required_status_checks")] | length' 2>/dev/null); then + --jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]? | select((.context? | type) == "string" and (.context | length) > 0)] | length' 2>/dev/null); then case "$RULES_QUERY" in ''|*[!0-9]*) ;; *) RULES="$RULES_QUERY" ;; From 93e01eddb7b893a0e4e57e1d50df561df11ba155 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 12:53:34 +0200 Subject: [PATCH 13/17] fix(i18n): validate classic required checks (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 18 ++++++++++++++++++ .github/workflows/i18n-pipeline.yaml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index 785eb3a..9226ed7 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -56,4 +56,22 @@ abort "Ruleset fixture #{name} failed: #{output}" unless status.success? && output.strip == expected end +classic_check_filter = 'if (.checks? | type) == "array" then [.checks[] | select((.context? | type) == "string" and (.context | length) > 0)] | length else 0 end' +abort "Classic probe must count valid configured checks" unless auto_merge_script.include?("--jq '#{classic_check_filter}'") + +classic_fixtures = { + "missing checks" => ["{}", "0"], + "string checks" => ['{"checks":"oops"}', "0"], + "numeric checks" => ['{"checks":1}', "0"], + "empty checks" => ['{"checks":[]}', "0"], + "malformed check" => ['{"checks":[{}]}', "0"], + "one configured check" => ['{"checks":[{"context":"lint","app_id":1}]}', "1"], + "mixed checks" => ['{"checks":[{}, {"context":""}, {"context":"test"}]}', "1"], +}.freeze + +classic_fixtures.each do |name, (payload, expected)| + output, status = Open3.capture2e("jq", "-r", classic_check_filter, stdin_data: payload) + abort "Classic fixture #{name} failed: #{output}" unless status.success? && output.strip == expected +end + puts "i18n pipeline authentication boundary: PASS" diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index e1367c3..2e5123b 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -467,7 +467,7 @@ jobs: fi CLASSIC=0 if CLASSIC_QUERY=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${GITHUB_REF_NAME}/protection/required_status_checks" \ - --jq '.checks | length' 2>/dev/null); then + --jq 'if (.checks? | type) == "array" then [.checks[] | select((.context? | type) == "string" and (.context | length) > 0)] | length else 0 end' 2>/dev/null); then case "$CLASSIC_QUERY" in ''|*[!0-9]*) ;; *) CLASSIC="$CLASSIC_QUERY" ;; From b54d952e3eecb1c5f6024b6524d2fd00c94e19c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 12:58:26 +0200 Subject: [PATCH 14/17] fix(i18n): tolerate malformed ruleset entries (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 3 ++- .github/workflows/i18n-pipeline.yaml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index 9226ed7..e4b1e61 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -40,13 +40,14 @@ abort "Ruleset count must default before the API probe" unless auto_merge_script.include?("RULES=0") abort "Classic protection count must default before the API probe" unless auto_merge_script.include?("CLASSIC=0") -ruleset_check_filter = '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]? | select((.context? | type) == "string" and (.context | length) > 0)] | length' +ruleset_check_filter = 'if type == "array" then [.[] | select(type == "object") | select(.type? == "required_status_checks") | .parameters? | select(type == "object") | .required_status_checks? | select(type == "array") | .[] | select(type == "object") | select((.context? | type) == "string" and (.context | length) > 0)] | length else 0 end' abort "Ruleset probe must count configured checks, not rule objects" unless auto_merge_script.include?("--jq '#{ruleset_check_filter}'") ruleset_fixtures = { "no rules" => ["[]", "0"], "empty required-check rule" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[]}}]', "0"], "malformed required check" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{}]}}]', "0"], + "malformed rule beside valid check" => ['[{"type":"required_status_checks","parameters":1},{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"}]}}]', "1"], "one configured check" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"}]}}]', "1"], "two rules with three checks" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"},{"context":"test"}]}},{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"build"}]}}]', "3"], }.freeze diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 2e5123b..6197b26 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -459,7 +459,7 @@ jobs: # and that push triggers the consumers' production deploy workflows. RULES=0 if RULES_QUERY=$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${GITHUB_REF_NAME}" \ - --jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]? | select((.context? | type) == "string" and (.context | length) > 0)] | length' 2>/dev/null); then + --jq 'if type == "array" then [.[] | select(type == "object") | select(.type? == "required_status_checks") | .parameters? | select(type == "object") | .required_status_checks? | select(type == "array") | .[] | select(type == "object") | select((.context? | type) == "string" and (.context | length) > 0)] | length else 0 end' 2>/dev/null); then case "$RULES_QUERY" in ''|*[!0-9]*) ;; *) RULES="$RULES_QUERY" ;; From 54cba7735b0918e7921bed731929dfb2711a2d4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 13:01:55 +0200 Subject: [PATCH 15/17] fix(i18n): validate language list before translation (CEL-1259) --- .../i18n-pipeline-translation-cache.test.rb | 53 +++++++++++++++++++ .github/workflows/i18n-pipeline.yaml | 15 ++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.github/tests/i18n-pipeline-translation-cache.test.rb b/.github/tests/i18n-pipeline-translation-cache.test.rb index ace6623..35b3277 100644 --- a/.github/tests/i18n-pipeline-translation-cache.test.rb +++ b/.github/tests/i18n-pipeline-translation-cache.test.rb @@ -1,3 +1,5 @@ +require "open3" +require "tmpdir" require "yaml" workflow_path = File.expand_path("../workflows/i18n-pipeline.yaml", __dir__) @@ -20,4 +22,55 @@ abort "Each language must receive its own cache copy" unless script.include?('--cache-file "$language_cache"') abort "Updated source hashes must return to the tracked cache" unless script.include?('cp "$updated_cache" .polyglot-cache.json') +def run_translation(script, target_languages) + Dir.mktmpdir("i18n-pipeline-test") do |workdir| + bin_dir = File.join(workdir, "bin") + Dir.mkdir(bin_dir) + call_log = File.join(workdir, "pnpm-calls.log") + pnpm_path = File.join(bin_dir, "pnpm") + File.write(pnpm_path, <<~'BASH') + #!/usr/bin/env bash + set -euo pipefail + language="" + cache_file="" + previous="" + for argument in "$@"; do + if [ "$previous" = "--output-languages" ]; then language="$argument"; fi + if [ "$previous" = "--cache-file" ]; then cache_file="$argument"; fi + previous="$argument" + done + printf '%s:%s\n' "$language" "$(tr -d '\n' < "$cache_file")" >> "$PNPM_CALL_LOG" + printf '{"updated":"%s"}\n' "$language" > "$cache_file" + BASH + File.chmod(0o755, pnpm_path) + + env = { + "FORCE_TRANSLATE" => "false", + "GOOGLE_API_KEY" => "test-key", + "LOCALES_PATH" => "public/locales", + "PATH" => "#{bin_dir}:#{ENV.fetch("PATH")}", + "PNPM_CALL_LOG" => call_log, + "RUNNER_TEMP" => workdir, + "TARGET_LANGUAGES" => target_languages, + "TRANSLATION_CONTEXT" => "test", + } + stdout, stderr, status = Open3.capture3(env, "bash", "-c", script, chdir: workdir) + calls = File.exist?(call_log) ? File.readlines(call_log, chomp: true) : [] + cache_path = File.join(workdir, ".polyglot-cache.json") + cache = File.exist?(cache_path) ? File.read(cache_path) : nil + [stdout + stderr, status, calls, cache] + end +end + +["", " ", ",sv", "sv,", "sv,,de", "sv, ,de"].each do |target_languages| + output, status, calls, = run_translation(script, target_languages) + abort "Invalid languages #{target_languages.inspect} must fail with validation" if status.success? || !output.include?("Target language code must not be empty") + abort "Invalid languages #{target_languages.inspect} must fail before translation" unless calls.empty? +end + +_output, status, calls, cache = run_translation(script, "sv, de") +abort "Valid languages must translate successfully" unless status.success? +abort "Every language must receive the untouched cache" unless calls == ["sv:{}", "de:{}"] +abort "Tracked cache must receive one updated language cache" unless cache == "{\"updated\":\"de\"}\n" + puts "i18n pipeline per-language cache isolation: PASS" diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 6197b26..3f19004 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -207,20 +207,27 @@ jobs: # after its first language, causing later languages to skip changed # keys they already contain. Give every language the same original # cache snapshot, then keep one equivalent updated copy. + if [[ -z "$TARGET_LANGUAGES" ]]; then + echo "::error::Target language code must not be empty" + exit 1 + fi if [[ "$TARGET_LANGUAGES" == *, ]]; then echo "::error::Target language code must not be empty" exit 1 fi IFS=',' read -r -a languages <<< "$TARGET_LANGUAGES" - updated_cache="" - index=0 - for language in "${languages[@]}"; do - language="$(printf '%s' "$language" | tr -d '[:space:]')" + for language_index in "${!languages[@]}"; do + language="$(printf '%s' "${languages[language_index]}" | tr -d '[:space:]')" if [ -z "$language" ]; then echo "::error::Target language code must not be empty" exit 1 fi + languages[language_index]="$language" + done + updated_cache="" + index=0 + for language in "${languages[@]}"; do language_cache="$RUNNER_TEMP/polyglot-cache-$index.json" cp "$BASE_CACHE" "$language_cache" translate_args=( From dbd92b889cadfaf95d70a6681231bf587cfe2962 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 13:11:52 +0200 Subject: [PATCH 16/17] fix(i18n): reject multiline language inputs (CEL-1259) --- .../tests/i18n-pipeline-translation-cache.test.rb | 14 ++++++++++++-- .github/workflows/i18n-pipeline.yaml | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/tests/i18n-pipeline-translation-cache.test.rb b/.github/tests/i18n-pipeline-translation-cache.test.rb index 35b3277..0e5d3b4 100644 --- a/.github/tests/i18n-pipeline-translation-cache.test.rb +++ b/.github/tests/i18n-pipeline-translation-cache.test.rb @@ -62,9 +62,19 @@ def run_translation(script, target_languages) end end -["", " ", ",sv", "sv,", "sv,,de", "sv, ,de"].each do |target_languages| +invalid_languages = { + "" => "Target language code must not be empty", + " " => "Target language code must not be empty", + ",sv" => "Target language code must not be empty", + "sv," => "Target language code must not be empty", + "sv,,de" => "Target language code must not be empty", + "sv, ,de" => "Target language code must not be empty", + "sv\nde" => "Target language code must not contain line breaks", +}.freeze + +invalid_languages.each do |target_languages, expected_error| output, status, calls, = run_translation(script, target_languages) - abort "Invalid languages #{target_languages.inspect} must fail with validation" if status.success? || !output.include?("Target language code must not be empty") + abort "Invalid languages #{target_languages.inspect} must fail with validation" if status.success? || !output.include?(expected_error) abort "Invalid languages #{target_languages.inspect} must fail before translation" unless calls.empty? end diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 3f19004..15a7759 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -211,6 +211,10 @@ jobs: echo "::error::Target language code must not be empty" exit 1 fi + if [[ "$TARGET_LANGUAGES" == *$'\n'* || "$TARGET_LANGUAGES" == *$'\r'* ]]; then + echo "::error::Target language code must not contain line breaks" + exit 1 + fi if [[ "$TARGET_LANGUAGES" == *, ]]; then echo "::error::Target language code must not be empty" exit 1 From 151eada5e86bcc9ab8bc0259eefdccc1ec6d6e9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 13:12:11 +0200 Subject: [PATCH 17/17] fix(i18n): roll back orphan branches (CEL-1259) --- .github/tests/i18n-pipeline-auth.test.rb | 41 ++++++++++++++++++++++++ .github/workflows/i18n-pipeline.yaml | 10 ++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb index e4b1e61..34ff61c 100644 --- a/.github/tests/i18n-pipeline-auth.test.rb +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -1,4 +1,5 @@ require "open3" +require "tmpdir" require "yaml" workflow_path = File.expand_path("../workflows/i18n-pipeline.yaml", __dir__) @@ -34,6 +35,46 @@ abort "Credentials must be cleaned immediately after push" unless cleanup_index && cleanup_index > push_index abort "Credential cleanup trap must be cleared after success" unless clear_trap_index && clear_trap_index > cleanup_index +Dir.mktmpdir("i18n-pr-rollback-test") do |workdir| + bin_dir = File.join(workdir, "bin") + Dir.mkdir(bin_dir) + gh_log = File.join(workdir, "gh.log") + git_log = File.join(workdir, "git.log") + + git_path = File.join(bin_dir, "git") + File.write(git_path, <<~'BASH') + #!/usr/bin/env bash + printf 'git %s\n' "$*" >> "$GIT_CALL_LOG" + BASH + File.chmod(0o755, git_path) + + gh_path = File.join(bin_dir, "gh") + File.write(gh_path, <<~'BASH') + #!/usr/bin/env bash + printf 'gh %s\n' "$*" >> "$GH_CALL_LOG" + if [[ "$*" == *"repos/CellarNode/test/pulls"* ]]; then + exit 1 + fi + BASH + File.chmod(0o755, gh_path) + + env = { + "GH_CALL_LOG" => gh_log, + "GH_TOKEN" => "test-token", + "GITHUB_OUTPUT" => File.join(workdir, "github-output"), + "GITHUB_REF_NAME" => "main", + "GITHUB_REPOSITORY" => "CellarNode/test", + "GIT_CALL_LOG" => git_log, + "LOCALES_PATH" => "public/locales", + "PATH" => "#{bin_dir}:#{ENV.fetch("PATH")}", + "RUNNER_TEMP" => workdir, + } + _stdout, _stderr, status = Open3.capture3(env, "bash", "-c", script, chdir: workdir) + gh_calls = File.exist?(gh_log) ? File.read(gh_log) : "" + abort "PR creation failure must fail the step" if status.success? + abort "PR creation failure must delete the pushed branch" unless gh_calls.include?("--method DELETE repos/CellarNode/test/git/refs/heads/chore/i18n-pipeline-") +end + auto_merge = steps.find { |step| step.fetch("name", "") == "Enable auto-merge on the new PR" } auto_merge_script = auto_merge.fetch("run") abort "Branch-rule API failures must not append response JSON to zero" if auto_merge_script.include?("|| echo 0") diff --git a/.github/workflows/i18n-pipeline.yaml b/.github/workflows/i18n-pipeline.yaml index 15a7759..838f35f 100644 --- a/.github/workflows/i18n-pipeline.yaml +++ b/.github/workflows/i18n-pipeline.yaml @@ -366,12 +366,18 @@ jobs: # proved why checkout credentials must be disabled: github.token # authenticated the push while a shadowed stale PAT failed this REST # call with HTTP 401. - PR_NUMBER=$(gh api "repos/${GITHUB_REPOSITORY}/pulls" \ + if ! PR_NUMBER=$(gh api "repos/${GITHUB_REPOSITORY}/pulls" \ -f title="chore(i18n): sync + translate" \ -F body=@"$RUNNER_TEMP/i18n-pr-body.md" \ -f base="${GITHUB_REF_NAME}" \ -f head="$BRANCH" \ - --jq '.number') + --jq '.number'); then + echo "::error::PR creation failed; deleting pushed branch $BRANCH" + if ! gh api --method DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/$BRANCH" >/dev/null; then + echo "::warning::Could not delete orphan branch $BRANCH after PR creation failed; remove it manually." + fi + exit 1 + fi echo "Opened PR #$PR_NUMBER" case "$PR_NUMBER" in ''|*[!0-9]*)