Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions .github/workflows/l10n-pot-comment.yml
Original file line number Diff line number Diff line change
@@ -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 = '<!-- l10n-automation:comment=messages-pot -->';

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}.`);
}
113 changes: 113 additions & 0 deletions .github/workflows/l10n-pot-diff.yml
Original file line number Diff line number Diff line change
@@ -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
Loading