diff --git a/.github/tests/i18n-pipeline-auth.test.rb b/.github/tests/i18n-pipeline-auth.test.rb new file mode 100644 index 0000000..34ff61c --- /dev/null +++ b/.github/tests/i18n-pipeline-auth.test.rb @@ -0,0 +1,119 @@ +require "open3" +require "tmpdir" +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", "") == "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 }}" + +script = create_pr.fetch("run") +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 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"') +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 + +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") +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 = '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 + +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 + +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/tests/i18n-pipeline-translation-cache.test.rb b/.github/tests/i18n-pipeline-translation-cache.test.rb new file mode 100644 index 0000000..0e5d3b4 --- /dev/null +++ b/.github/tests/i18n-pipeline-translation-cache.test.rb @@ -0,0 +1,86 @@ +require "open3" +require "tmpdir" +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 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"') +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 + +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?(expected_error) + 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 676ca20..838f35f 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: @@ -125,7 +123,12 @@ 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 + # 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 @@ -156,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 @@ -184,22 +187,74 @@ 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. + if [[ -z "$TARGET_LANGUAGES" ]]; then + 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 + fi + IFS=',' read -r -a languages <<< "$TARGET_LANGUAGES" + 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=( + --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 @@ -264,26 +319,38 @@ 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)" + 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") 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" - # 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. - 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 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" + git config --local credential.helper "store --file=$GIT_CREDENTIAL_FILE" + git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + 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. @@ -294,18 +361,23 @@ 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. - PR_NUMBER=$(gh api "repos/${GITHUB_REPOSITORY}/pulls" \ + # 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. + 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]*) @@ -384,40 +456,40 @@ 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, 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 - # 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 # 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 '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" ;; + esac + fi + CLASSIC=0 + if CLASSIC_QUERY=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${GITHUB_REF_NAME}/protection/required_status_checks" \ + --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" ;; + 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 diff --git a/.github/workflows/validate-i18n-pipeline.yaml b/.github/workflows/validate-i18n-pipeline.yaml new file mode 100644 index 0000000..9d9191e --- /dev/null +++ b/.github/workflows/validate-i18n-pipeline.yaml @@ -0,0 +1,32 @@ +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@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + + - 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