name: Lingvo reviewed translation update

# Opt-in alternative to the ordinary pull-translations job. Disable that updater before adopting
# this recipe: both use the same stable branch. Keep its push-sources job if you need it.
# Requires a compatible CLI/server; published CLI 0.3.0 lacks the checkout policy flags.
# Readable action tags follow the other examples; apply your repository's action pinning policy.
on:
  schedule:
    - cron: "17 3 * * 1-5"
  workflow_dispatch:

permissions:
  contents: read

env:
  # Independently review the version and ZIP digest; do not download a fresh digest each run.
  LINGVO_VERSION: ${{ vars.LINGVO_REVIEWED_CLI_VERSION }}
  LINGVO_SHA256: ${{ vars.LINGVO_REVIEWED_CLI_SHA256 }}

concurrency:
  group: lingvo-chore-translation-update
  cancel-in-progress: false

jobs:
  reviewed-translations:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          # Never run a dispatch ref or the generated PR branch with the hosted credential.
          ref: ${{ github.event.repository.default_branch }}
          persist-credentials: false
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
      - name: Install verified compatible Lingvo CLI
        shell: bash
        run: |
          set -euo pipefail
          [[ "$LINGVO_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$ ]]
          [[ "$LINGVO_SHA256" =~ ^[a-fA-F0-9]{64}$ ]]
          archive="$RUNNER_TEMP/lingvo-reviewed-cli.zip"
          curl --fail --silent --show-error --location --proto '=https' \
            "https://github.com/Merkost/homebrew-lingvo/releases/download/v${LINGVO_VERSION}/lingvo-${LINGVO_VERSION}.zip" \
            --output "$archive"
          printf '%s  %s\n' "$LINGVO_SHA256" "$archive" | sha256sum --check --status
          unzip -q "$archive" -d "$RUNNER_TEMP/lingvo-cli"
          chmod +x "$RUNNER_TEMP/lingvo-cli/lingvo-${LINGVO_VERSION}/bin/lingvo"
          echo "$RUNNER_TEMP/lingvo-cli/lingvo-${LINGVO_VERSION}/bin" >> "$GITHUB_PATH"
      - name: Pull approved translations and check checkout
        id: reviewed
        shell: bash
        env:
          # Custom grant: ONLY strings:read + qa:run, restricted to the configured project.
          LINGVO_TOKEN: ${{ secrets.LINGVO_REVIEWED_TOKEN }}
          LINGVO_REVIEW_LOCALES: ${{ vars.LINGVO_REVIEW_LOCALES }}
          LINGVO_REVIEW_DIRECTORY: ${{ vars.LINGVO_REVIEW_DIRECTORY || '.' }}
        run: |
          set -euo pipefail
          python3 - <<'PY'
          import hashlib
          import json
          import os
          from pathlib import Path, PurePosixPath
          import re
          import subprocess
          import sys
          import tempfile
          import uuid

          os.umask(0o077)
          token = os.environ.pop("LINGVO_TOKEN", "")
          base_env = {key: value for key, value in os.environ.items()
                      if not key.startswith("LINGVO_")}
          base_env["NO_COLOR"] = "1"
          locale_pattern = re.compile(r"[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-[A-Za-z0-9]{4,8})*")
          formats = {"ANDROID_XML", "IOS_STRINGS", "IOS_STRINGSDICT", "XCSTRINGS",
                     "JSON_NESTED", "JSON_FLAT", "PROPERTIES", "YAML", "CSV", "XLIFF", "PO"}

          def refuse(message, code=2):
              # Never echo child diagnostics, raw receipts, paths or credentials to runner logs.
              print("Lingvo reviewed update: " + message, file=sys.stderr)
              raise SystemExit(code)

          def require(condition, message):
              if not condition:
                  refuse(message)

          def run(args, authenticated=False, at_workspace=False):
              environment = dict(base_env)
              if authenticated:
                  environment["LINGVO_TOKEN"] = token
              result = subprocess.run(args, cwd=workspace if at_workspace else directory,
                                      env=environment, capture_output=True)
              if result.returncode:
                  refuse("A required command failed; no PR is authorized.",
                         result.returncode if 0 < result.returncode < 126 else 2)
              return result.stdout

          def document(args, command, name):
              raw = run(["lingvo", "--json", *args], authenticated=True)
              (reports / name).write_bytes(raw)
              try:
                  envelope = json.loads(raw)
              except (ValueError, UnicodeError):
                  refuse("The CLI returned an unreadable receipt.")
              require(isinstance(envelope, dict) and envelope.get("schemaVersion") == "1.0"
                      and envelope.get("command") == command and envelope.get("ok") is True
                      and isinstance(envelope.get("data"), dict), "The CLI receipt is incompatible.")
              return envelope["data"]

          def safe_relative(value):
              require(isinstance(value, str) and bool(value)
                      and not any(char in value for char in "\x00\r\n,\\"),
                      "A resource path cannot be represented safely in the PR scope.")
              path = PurePosixPath(value)
              require(not path.is_absolute() and ".." not in path.parts and str(path) == value,
                      "A resource path is not checkout-relative.")
              return path

          def physical(value):
              path = directory / safe_relative(value)
              resolved = path.resolve()
              require(resolved.is_relative_to(directory) and path == resolved and resolved.is_file(),
                      "A captured resource is missing, aliased or outside the project.")
              require(resolved != directory / "lingvo.yaml" and ".git" not in resolved.relative_to(workspace).parts,
                      "A captured resource overlaps configuration or Git metadata.")
              return resolved

          def changed_paths():
              changed = run(["git", "diff", "--name-only", "-z", "HEAD", "--"], at_workspace=True)
              extra = run(["git", "ls-files", "--others", "--exclude-standard", "-z"], at_workspace=True)
              try:
                  return {value.decode("utf-8") for value in (changed + extra).split(b"\0") if value}
              except UnicodeError:
                  refuse("A changed Git path is not UTF-8.")

          try:
              require(bool(token.strip()), "Set the separate project-scoped reviewed token.")
              workspace = Path(os.environ["GITHUB_WORKSPACE"]).resolve(strict=True)
              configured = os.environ.get("LINGVO_REVIEW_DIRECTORY", ".")
              require(not Path(configured).is_absolute(), "Use a checkout-relative project directory.")
              directory = (workspace / configured).resolve(strict=True)
              require(directory.is_relative_to(workspace) and (directory / "lingvo.yaml").is_file(),
                      "The project directory must contain its own lingvo.yaml inside the checkout.")
              runner_temp = Path(os.environ["RUNNER_TEMP"]).resolve(strict=True)
              require(not runner_temp.is_relative_to(workspace), "Keep reports outside the checkout.")
              reports = Path(tempfile.mkdtemp(prefix="lingvo-reviewed-", dir=runner_temp))
              require(Path(run(["git", "rev-parse", "--show-toplevel"]).decode().strip()).resolve() == workspace,
                      "The checkout root does not match the workspace.")
              require(not run(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"]),
                      "Start from a clean default-branch checkout.")
              requested = [value.strip() for value in os.environ.get("LINGVO_REVIEW_LOCALES", "").split(",")]
              require(1 <= len(requested) <= 25 and all(locale_pattern.fullmatch(value) for value in requested)
                      and len({value.lower() for value in requested}) == len(requested),
                      "Set 1 to 25 distinct explicit target locales.")
              for command, flags in (("qa", ("--local", "--locales", "--require-reviewed-current", "--fail-on")),
                                     ("pull", ("--approved-only",))):
                  help_text = run(["lingvo", command, "--help"]).decode("utf-8")
                  require(all(re.search(r"(?<![\w-])" + re.escape(flag) + r"(?=[\s=,]|$)", help_text)
                              for flag in flags), "Pin a CLI that supports every required checkout-policy flag.")
              status = document(["status"], "status", "status.json")
              project, source = status.get("project"), status.get("sourceLocale")
              require(isinstance(project, str) and bool(project) and isinstance(source, str)
                      and locale_pattern.fullmatch(source), "The hosted project identity is incomplete.")
              enabled = status.get("locales")
              require(isinstance(enabled, list) and bool(enabled), "The enabled locale list is missing.")
              labels = [item.get("locale") if isinstance(item, dict) else None for item in enabled]
              require(all(isinstance(label, str) and locale_pattern.fullmatch(label) for label in labels)
                      and len({label.lower() for label in labels}) == len(labels),
                      "The enabled locale list is ambiguous.")
              lookup = {label.lower(): label for label in labels}
              require(all(value.lower() in lookup and value.lower() != source.lower() for value in requested),
                      "Select enabled target locales; exclude the source locale.")
              locales = [lookup[value.lower()] for value in requested]
              for index, locale in enumerate(locales):
                  document(["pull", "--locale", locale, "--approved-only"], "pull", f"pull-{index}.json")
              data = document(["qa", "--local", "--locales", ",".join(locales),
                               "--require-reviewed-current", "--fail-on", "error"], "qa", "qa.json")
              local, hosted = data.get("localReport"), data.get("reviewedCurrentReport")
              require(data.get("mode") == "reviewed-current" and data.get("gatePassed") is True
                      and isinstance(local, dict) and local.get("complete") is True
                      and isinstance(hosted, dict) and hosted.get("complete") is True
                      and hosted.get("passed") is True and hosted.get("policy") == "REVIEWED_CURRENT",
                      "The complete reviewed/current checkout gate did not pass.")
              # Local default warnings do not override the hosted project's configured policy/exemptions.
              scope = hosted.get("scope")
              require(isinstance(scope, dict) and scope.get("kind") == "CHECKOUT_RESOURCES"
                      and scope.get("projectSlug") == project and scope.get("sourceLocale") == source
                      and isinstance(scope.get("locales"), list) and len(scope["locales"]) == len(locales)
                      and set(scope["locales"]) == set(locales), "The gate covers a different project or locale set.")
              logical = scope.get("filePaths")
              require(isinstance(logical, list) and bool(logical)
                      and all(isinstance(value, str) for value in logical) and len(set(logical)) == len(logical),
                      "The logical file scope is incomplete.")
              digests = hosted.get("digests")
              local_digests = local.get("digests")
              require(isinstance(digests, list) and bool(digests) and isinstance(local_digests, list)
                      and sorted(json.dumps(value, sort_keys=True) for value in local_digests)
                      == sorted(json.dumps(value, sort_keys=True) for value in digests),
                      "The captured resource receipts disagree or are missing.")
              uses, coverage, allowed = set(), set(), set()
              for digest in digests:
                  require(isinstance(digest, dict), "A captured resource receipt is malformed.")
                  logical_path, locale, role = digest.get("logicalPath"), digest.get("locale"), digest.get("role")
                  require(logical_path in logical and role in {"SOURCE", "TARGET"}
                          and locale in ([source] if role == "SOURCE" else locales)
                          and digest.get("format") in formats, "A resource has an incompatible role or format.")
                  path = physical(digest.get("path"))
                  identity = (logical_path, digest["path"], locale, role)
                  require(identity not in uses, "A resource use is duplicated.")
                  uses.add(identity)
                  coverage.add((logical_path, locale, role))
                  content = path.read_bytes()
                  require(isinstance(digest.get("sha256"), str)
                          and re.fullmatch(r"[a-fA-F0-9]{64}", digest["sha256"])
                          and type(digest.get("byteCount")) is int and digest["byteCount"] == len(content)
                          and hashlib.sha256(content).hexdigest() == digest["sha256"].lower(),
                          "Captured resource bytes changed or their digest is invalid.")
                  if role == "TARGET":
                      allowed.add(path.relative_to(workspace).as_posix())
              expected = {(value, source, "SOURCE") for value in logical}
              expected.update((value, locale, "TARGET") for value in logical for locale in locales)
              require(coverage == expected, "The captured file/locale scope is incomplete.")
              lock = directory / ".lingvo-lock.json"
              require(lock.is_file() and lock.resolve() == lock, "The pull lockfile is missing or aliased.")
              allowed.add(lock.relative_to(workspace).as_posix())
              for value in allowed:
                  safe_relative(value)
              require(changed_paths().issubset(allowed), "Unexpected files changed; inspect the checkout before retrying.")
              delimiter = "lingvo_" + uuid.uuid4().hex
              with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
                  output.write("verified=true\nlocales=" + ",".join(locales) + "\npaths<<" + delimiter + "\n")
                  output.write("\n".join(":(literal)" + value for value in sorted(allowed)) + "\n" + delimiter + "\n")
              print("Approved-only pulls and the complete checkout gate passed; PR scope is verified.")
          except (OSError, ValueError, TypeError, KeyError, UnicodeError):
              refuse("Unable to verify the complete checkout; no PR is authorized.")
          PY
      - name: Open reviewed translation update pull request
        if: steps.reviewed.outputs.verified == 'true'
        uses: peter-evans/create-pull-request@v7
        with:
          add-paths: ${{ steps.reviewed.outputs.paths }}
          branch: chore/lingvo-translation-update
          delete-branch: true
          commit-message: "chore: update reviewed translations"
          title: "chore: update reviewed translations"
          body: |
            Approved-only Lingvo pulls for ${{ steps.reviewed.outputs.locales }}.
            The complete captured checkout passed the REVIEWED_CURRENT policy at check time.
            Only checked target resources (including companions/shared catalogues) and the sync lock are included.
            Review this diff and run the app's native checks before merging. This is not release acceptance.
          labels: localization
