From 62c34ab502ba3e4e2efe2f3e3df03a60627a6311 Mon Sep 17 00:00:00 2001 From: okaybro <66475772+Chaitanya-Keyal@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:44:42 +0530 Subject: [PATCH] feat(l10n): advisory messages.pot diff comment on PRs Adds the read-only producer and the trusted follow-up consumer: - l10n-pot-diff.yml runs on pull_request with no secrets and a read-only token; it extracts the catalog from the base and the PR-head source, diffs them, and uploads a schema-constrained manifest artifact. - l10n-pot-comment.yml runs on workflow_run from default-branch code; it validates the manifest (schema + repository/head-SHA trust anchors, with an authoritative PR-number to head-SHA binding) and maintains a single advisory comment. Depends on the contract, diff engine, renderer, and validator changes. --- .github/workflows/l10n-pot-comment.yml | 145 +++++++++++++++++++++++++ .github/workflows/l10n-pot-diff.yml | 113 +++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 .github/workflows/l10n-pot-comment.yml create mode 100644 .github/workflows/l10n-pot-diff.yml diff --git a/.github/workflows/l10n-pot-comment.yml b/.github/workflows/l10n-pot-comment.yml new file mode 100644 index 000000000..6f083dec9 --- /dev/null +++ b/.github/workflows/l10n-pot-comment.yml @@ -0,0 +1,145 @@ +# Posts the advisory source-string comment on a feature PR. +# +# This is the write-capable half of the diff/comment pair. It is triggered by +# the read-only "l10n source-string diff" run completing, and runs code from the +# default branch rather than the PR, so it never executes PR-supplied code. It +# validates the uploaded manifest (schema plus trust anchors), re-renders the +# comment from the validated fields, and maintains a single comment per PR. +name: l10n source-string comment + +on: + workflow_run: + workflows: ["l10n source-string diff"] + types: [completed] + +permissions: + contents: read + actions: read + pull-requests: write + +concurrency: + group: l10n-pot-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + comment: + # Only act on successful diff runs that came from a pull request. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout default-branch automation code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: false + + - name: Download manifest artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: l10n-review-manifest + path: incoming + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install validation deps + # Only jsonschema is needed here (validate_manifest.py); render_comment.py + # is stdlib-only. This is the sole consumer, so it is pinned inline rather + # than in l10n/requirements-l10n.txt (which is for message extraction). + run: python -m pip install --quiet "jsonschema==4.26.0" + + - name: Validate manifest + env: + EXPECT_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + EXPECT_REPO: ${{ github.repository }} + run: | + set -euo pipefail + python l10n/automation/validate_manifest.py \ + --manifest incoming/manifest.json \ + --schema l10n/automation/schema/review-manifest.schema.json \ + --expect-head-sha "$EXPECT_HEAD_SHA" \ + --expect-repository "$EXPECT_REPO" + + - name: Render comment + run: | + set -euo pipefail + python l10n/automation/render_comment.py \ + --manifest incoming/manifest.json \ + --out comment.md + + - name: Upsert advisory comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + with: + script: | + const fs = require('fs'); + const MARKER = ''; + + const manifest = JSON.parse(fs.readFileSync('incoming/manifest.json', 'utf8')); + const body = fs.readFileSync('comment.md', 'utf8'); + const prNumber = manifest.pr.number; + const runHeadSha = process.env.RUN_HEAD_SHA; + + // Authoritative PR-number <-> head-SHA binding: confirm the PR we are + // about to comment on really has the head commit this run was built + // from. Prevents a malicious PR from targeting another PR's thread. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pr.head.sha !== runHeadSha) { + // The manifest's head SHA was already confirmed to equal this run's + // head by validate_manifest, so a mismatch here means the PR has + // since advanced (a benign race with a newer push) or the manifest + // named someone else's PR. Either way the safe action is to not + // comment; skip neutrally rather than failing the run with noise. + core.warning( + `PR #${prNumber} head ${pr.head.sha} != run head ${runHeadSha}; ` + + `skipping comment (PR advanced, or PR number does not match this run).`); + return; + } + + // The login this token comments as (e.g. "github-actions[bot]"); the + // [bot] suffix is normalized away so REST and GraphQL forms compare equal. + const { viewer } = await github.graphql('query { viewer { login } }'); + const myLogin = viewer.login.replace(/\[bot\]$/, ''); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + // Match our own previous comment: the marker identifies the stream, + // and requiring our exact author login means a human (or any other + // bot) carrying the marker can never be mistaken for the one we maintain. + const existing = comments.find(c => + c.user && c.user.login.replace(/\[bot\]$/, '') === myLogin && + c.body && c.body.includes(MARKER)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + core.info(`Updated advisory comment ${existing.id} on PR #${prNumber}.`); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + core.info(`Created advisory comment on PR #${prNumber}.`); + } diff --git a/.github/workflows/l10n-pot-diff.yml b/.github/workflows/l10n-pot-diff.yml new file mode 100644 index 000000000..2bc889139 --- /dev/null +++ b/.github/workflows/l10n-pot-diff.yml @@ -0,0 +1,113 @@ +# Computes the source-string diff for a feature PR. +# +# This runs in the PR context, which is untrusted for fork PRs, so it has no +# secrets and a read-only token. It can safely run PR code (extract_messages) +# because it has no write access and nothing to leak. It regenerates the catalog +# from both the base branch source and the PR head source and diffs them, so the +# result reflects exactly the source strings this PR changes -- it never reads +# the committed messages.pot, which is maintained automatically after merge. The +# schema-constrained manifest it uploads is turned into a PR comment by the +# l10n-pot-comment workflow. +name: l10n source-string diff + +on: + pull_request: + paths: + - "src/**" + - "setup.cfg" + +permissions: + contents: read + +concurrency: + group: l10n-pot-diff-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + diff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout PR head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + submodules: false + + - name: Fetch base branch + id: base + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + git fetch --quiet --depth 1 origin "$BASE_REF" + echo "base_sha=$(git rev-parse "origin/$BASE_REF")" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install Python dependencies + run: python -m pip install --quiet -r l10n/requirements-l10n.txt + + - name: Regenerate catalog from base and head source + id: regen + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -uo pipefail + ok=true + + # Head catalog: extracted from the checked-out PR source. + python setup.py extract_messages -o head.pot || ok=false + [ -s head.pot ] || { : > head.pot; ok=false; } + + # Base catalog: extracted from the base branch in a detached worktree, + # so the checked-out head tree is untouched. Falls back to the + # committed catalog if base extraction is unavailable. + base_ok=true + git worktree add --quiet --detach .l10n-base "origin/$BASE_REF" \ + && ( cd .l10n-base && python setup.py extract_messages -o "$GITHUB_WORKSPACE/base.pot" ) \ + || base_ok=false + git worktree remove --force .l10n-base 2>/dev/null || true + if [ "$base_ok" != true ] || [ ! -s base.pot ]; then + git show "origin/$BASE_REF:l10n/messages.pot" > base.pot 2>/dev/null || : > base.pot + ok=false + fi + + echo "ok=$ok" >> "$GITHUB_OUTPUT" + + - name: Build review manifest + env: + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ steps.base.outputs.base_sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + REGEN_OK: ${{ steps.regen.outputs.ok }} + run: | + set -euo pipefail + python l10n/automation/pot_diff.py \ + --base base.pot \ + --head head.pot \ + --repository "$REPO" \ + --pr-number "$PR_NUMBER" \ + --head-sha "$HEAD_SHA" \ + --base-sha "$BASE_SHA" \ + --base-ref "$BASE_REF" \ + --regen-ok "$REGEN_OK" \ + --out manifest.json + echo "::group::manifest.json" + cat manifest.json + echo "::endgroup::" + + - name: Upload manifest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: l10n-review-manifest + path: manifest.json + if-no-files-found: error + retention-days: 7