Skip to content

[release/11.0.1xx] Reconcile MTP vendored source drift #7988

[release/11.0.1xx] Reconcile MTP vendored source drift

[release/11.0.1xx] Reconcile MTP vendored source drift #7988

# This workflow updates XLF files when /updatexlf or /xlf is commented on a
# pull request.
# Action: https://github.com/dotnet/sdk/actions/workflows/update-xlf-on-comment.yml
# Keep documentation/project-docs/developer-guide.md up to date with changes in
# this workflow.
name: Update XLF files on command
on:
issue_comment:
types: [created]
permissions:
contents: read
env:
DOTNET_CLI_TELEMETRY_SESSIONID: gha-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}
REPO_NAME: ${{ github.event.repository.name }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
jobs:
authorize:
name: Authorize Request
if: github.event.issue.pull_request
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
outputs:
should_run: ${{ steps.command-filter.outputs.should_run }}
head_ref: ${{ steps.metadata.outputs.head_ref }}
steps:
- name: Evaluate comment command
id: command-filter
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENT_BODY: ${{ github.event.comment.body }} # GH Has a Character Limit which prevents overflow issues. https://github.com/dead-claudia/github-limits
with:
script: |
const body = (process.env.COMMENT_BODY || '').trim().toLowerCase();
const shouldRun = body.startsWith('/updatexlf') || body.startsWith('/xlf');
core.setOutput('should_run', String(shouldRun));
if (!shouldRun) {
core.info('Comment does not invoke /updatexlf or /xlf. Skipping workflow.');
}
- name: Ensure commenter is trusted
if: steps.command-filter.outputs.should_run == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
script: |
const { owner, repo } = context.repo;
const username = context.payload.comment.user.login;
if (!username) {
throw new Error('Unable to resolve commenter username from event payload.');
}
try {
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username,
});
const allowed = ['admin', 'write'];
if (!allowed.includes(permission.permission)) {
throw new Error(`@${username} has "${permission.permission}" access.`);
}
core.info(`Verified ${username} has ${permission.permission} access.`);
} catch (error) {
throw new Error(`Only collaborators with write access may trigger this workflow. ${error.message || error}`);
}
- name: React to comment
if: steps.command-filter.outputs.should_run == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.warning('No comment ID found on payload; skipping reaction.');
return;
}
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: Number(commentId),
content: 'eyes',
});
- name: Comment on PR - Started
if: steps.command-filter.outputs.should_run == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENT_BODY: ${{ format('▶️ XLF update workflow started. Track progress in [this workflow run]({0}).', env.RUN_URL) }}
with:
github-token: ${{ github.token }}
script: |
const { owner, repo } = context.repo;
await github.rest.issues.createComment({
owner,
repo,
issue_number: Number(context.payload.issue.number),
body: process.env.COMMENT_BODY,
});
- name: Capture PR metadata
id: metadata
if: steps.command-filter.outputs.should_run == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number,
});
if (pr.data.state !== 'open') {
core.setFailed(`Pull request #${pr.data.number} is not open.`);
return;
}
// Commits cannot be applied to a pull request whose head branch lives in a fork. Detect that and explain how to proceed.
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
const headRepo = pr.data.head.repo ? pr.data.head.repo.full_name : null;
if (headRepo !== baseRepo) {
const body = [
`❌ This command can't run on this pull request because its head branch lives in a fork`,
`(\`${headRepo || 'a deleted fork'}\`), not in \`${baseRepo}\`.`,
``,
`The GitHub Actions runner can only push to \`${baseRepo}\`; it has no access to push commits to your fork.`,
`To use this command, push your branch directly to the \`${baseRepo}\` remote and open the pull request`,
`from that branch (based off \`${baseRepo}\`), then comment the command again.`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(context.payload.issue.number),
body,
});
core.setFailed(`Pull request head branch is in a fork (${headRepo || 'deleted'}); the runner cannot push to it.`);
return;
}
core.setOutput('head_ref', pr.data.head.ref);
generate:
name: Generate XLF Updates
needs: authorize
if: needs.authorize.outputs.should_run == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
changes: ${{ steps.check.outputs.changes }}
diff_summary: ${{ steps.package.outputs.diff_summary }}
defaults:
run:
working-directory: pr
steps:
- name: Checkout PR head
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
token: ${{ github.token }}
fetch-depth: 0
ref: refs/pull/${{ github.event.issue.number }}/head
path: pr
persist-credentials: false
- name: Run UpdateXlf target
id: update
run: |
chmod +x ./build.sh
if ./build.sh /t:UpdateXlf; then
echo "mode=incremental" >> "$GITHUB_OUTPUT"
exit 0
fi
if ./build.sh; then
echo "mode=full" >> "$GITHUB_OUTPUT"
exit 0
fi
exit 1
continue-on-error: true
timeout-minutes: 20
env:
GITHUB_TOKEN: ""
GH_TOKEN: ""
- name: Check for XLF changes
id: check
if: steps.update.outcome == 'success'
run: |
set -euo pipefail
shopt -s globstar
mapfile -t changed_files < <(git diff --name-only)
if [ ${#changed_files[@]} -eq 0 ]; then
echo "changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
for file in "${changed_files[@]}"; do
if [[ "$file" != *.xlf ]]; then
echo "Unexpected file changed: $file" >&2
exit 1
fi
done
printf '%s\n' "${changed_files[@]}" > __changed-files.txt
echo "changes=true" >> "$GITHUB_OUTPUT"
- name: Evaluate generation result
id: evaluate
if: always()
run: |
status="success"
reason=""
if [ "${UPDATE_OUTCOME}" != "success" ]; then
status="failed"
reason="update"
fi
echo "status=$status" >> "$GITHUB_OUTPUT"
if [ -n "$reason" ]; then
echo "failure_reason=$reason" >> "$GITHUB_OUTPUT"
fi
env:
UPDATE_OUTCOME: ${{ steps.update.outcome }}
- name: Package XLF artifacts
id: package
if: steps.evaluate.outputs.status == 'success' && steps.check.outputs.changes == 'true'
run: |
mkdir -p __artifacts
# __changed-files.txt is only used to build the tarball; it is intentionally not included in the
# uploaded artifact because the 'apply' job derives the file list from the extracted tree, not this file.
git diff --stat -- '**/*.xlf' > __artifacts/diff-summary.txt
tar -czf __artifacts/xlf-files.tar.gz -T __changed-files.txt
printf 'diff_summary<<EOF\n%s\nEOF\n' "$(cat __artifacts/diff-summary.txt)" >> "$GITHUB_OUTPUT"
- name: Upload XLF artifacts
if: steps.evaluate.outputs.status == 'success' && steps.check.outputs.changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: xlf-updates
path: pr/__artifacts
if-no-files-found: error
- name: Create failure diagnostics
if: steps.evaluate.outputs.status == 'failed'
env:
FAILURE_REASON: ${{ steps.evaluate.outputs.failure_reason }}
run: |
mkdir -p __failure
if [ -n "${FAILURE_REASON}" ]; then
echo "${FAILURE_REASON}" > __failure/failure_reason.txt
else
echo "unknown" > __failure/failure_reason.txt
fi
- name: Upload failure diagnostics
if: steps.evaluate.outputs.status == 'failed'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: xlf-updates-failure
path: pr/__failure
if-no-files-found: ignore
- name: Fail job when update unsuccessful
if: steps.evaluate.outputs.status == 'failed'
run: |
echo "XLF update failed."
exit 1
apply:
name: Apply XLF Updates
needs: [authorize, generate]
if: needs.authorize.outputs.should_run == 'true' && needs.generate.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout PR branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
token: ${{ github.token }}
fetch-depth: 0
# Check out the PR head commit by pull-request ref (mirrors the generate job). The branch NAME
# (head_ref) is only resolvable for same-repo PRs; refs/pull/<n>/head resolves for any open PR, so
# the checkout no longer fails on fork PRs or when the head branch was renamed/deleted.
ref: refs/pull/${{ github.event.issue.number }}/head
persist-credentials: false
- name: Download XLF artifacts
if: needs.generate.outputs.changes == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: xlf-updates
path: artifact
- name: Checkout trusted validation script
if: needs.generate.outputs.changes == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
# Always use the canonical validator from the repository's default branch (main), never the PR's copy.
# issue_comment workflows already run from the default branch, so this also keeps the script version
# matched to this workflow definition regardless of which branch the PR targets.
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
path: _validator
sparse-checkout: |
.github/scripts/validate-change-archive.py
sparse-checkout-cone-mode: false
- name: Validate and stage XLF files
if: needs.generate.outputs.changes == 'true'
run: |
set -euo pipefail
# The artifact comes from the untrusted 'generate' job. Validate and extract it with the shared script
# checked out separately above from the trusted default branch (never the PR's copy), then stage only
# the repository-relative paths it approves.
if ! python3 _validator/.github/scripts/validate-change-archive.py \
--archive artifact/xlf-files.tar.gz \
--dest . \
--ext .xlf \
> __staged-files.nul; then
echo "Artifact validation failed." >&2
exit 1
fi
mapfile -d '' -t staged_files < __staged-files.nul
rm -f __staged-files.nul
if [ ${#staged_files[@]} -eq 0 ]; then
echo "No .xlf files were present in the artifact." >&2
exit 1
fi
git -c core.hooksPath=/dev/null -c core.attributesFile=/dev/null add -- "${staged_files[@]}"
- name: Prepare commit message
id: commit-message
if: needs.generate.outputs.changes == 'true'
shell: bash
run: |
COMMIT_DATE=$(date -u +"%Y-%m-%d")
printf 'message=Update XLF translation files - %s\n' "$COMMIT_DATE" >> "$GITHUB_OUTPUT"
- name: Commit XLF changes
id: commit
if: needs.generate.outputs.changes == 'true'
shell: bash
env:
COMMIT_MESSAGE: ${{ steps.commit-message.outputs.message }}
ALLOWED_PATTERNS: |
**/*.xlf
GIT_USER_NAME: github-actions[bot]
GIT_USER_EMAIL: github-actions[bot]@users.noreply.github.com
run: |
set -euo pipefail
shopt -s globstar
git config user.name "${GIT_USER_NAME}"
git config user.email "${GIT_USER_EMAIL}"
mapfile -t staged_files < <(git diff --cached --name-only)
if [ ${#staged_files[@]} -eq 0 ]; then
echo "No staged files were found. Nothing to commit." >&2
exit 1
fi
if [ -z "${ALLOWED_PATTERNS//,/ }" ]; then
echo "No allowed patterns were provided." >&2
exit 1
fi
patterns_normalised=$(printf '%s' "${ALLOWED_PATTERNS}" | tr ',' '\n')
mapfile -t allowed_patterns < <(printf '%s\n' "${patterns_normalised}" | sed -e 's/^\s*//' -e 's/\s*$//' | sed -e '/^$/d')
if [ ${#allowed_patterns[@]} -eq 0 ]; then
echo "Allowed pattern list is empty after normalisation." >&2
exit 1
fi
for file in "${staged_files[@]}"; do
match=false
for pattern in "${allowed_patterns[@]}"; do
if [[ "$file" == $pattern ]]; then
match=true
break
fi
done
if [ "$match" = false ]; then
echo "File '$file' does not match the allowed patterns." >&2
exit 1
fi
done
# Defense in depth: disable hooks and gitattributes-driven filters so a
# commit can never execute repository- or artifact-supplied code, even
# if an unexpected file slipped into .git/ earlier.
git -c core.hooksPath=/dev/null -c core.attributesFile=/dev/null commit -m "${COMMIT_MESSAGE}"
- name: Push XLF changes
if: steps.commit.outcome == 'success'
env:
HEAD_REF: ${{ needs.authorize.outputs.head_ref }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}" HEAD:"${HEAD_REF}"
- name: Prepare success comment
id: success-comment
if: steps.commit.outcome == 'success'
shell: bash
env:
DIFF_SUMMARY: ${{ needs.generate.outputs.diff_summary }}
run: |
body="✅ XLF translation files have been updated and committed to this PR. See [workflow details](${RUN_URL})."
if [ -n "${DIFF_SUMMARY}" ]; then
body="${body}"$'\n\n```\n'"${DIFF_SUMMARY}"$'\n```'
fi
printf 'body<<EOF\n%s\nEOF\n' "$body" >> "$GITHUB_OUTPUT"
- name: Comment on PR - Success
if: steps.commit.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENT_BODY: ${{ steps.success-comment.outputs.body }}
with:
github-token: ${{ github.token }}
script: |
const { owner, repo } = context.repo;
const reactionCommentId = context.payload.comment.id;
await github.rest.issues.createComment({
owner,
repo,
issue_number: Number(context.payload.issue.number),
body: process.env.COMMENT_BODY,
});
if (reactionCommentId) {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: Number(reactionCommentId),
content: '+1',
});
}
- name: Comment on PR - No changes
if: needs.generate.outputs.changes != 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENT_BODY: ${{ format('ℹ️ No XLF files needed to be updated. Review [the workflow run]({0}).', env.RUN_URL) }}
with:
github-token: ${{ github.token }}
script: |
const { owner, repo } = context.repo;
const reactionCommentId = context.payload.comment.id;
await github.rest.issues.createComment({
owner,
repo,
issue_number: Number(context.payload.issue.number),
body: process.env.COMMENT_BODY,
});
if (reactionCommentId) {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: Number(reactionCommentId),
content: '+1',
});
}
report-failure:
name: Report Failure
needs: [authorize, generate, apply]
if: needs.authorize.outputs.should_run == 'true' && needs.authorize.result == 'success' && failure()
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Download failure diagnostics
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: xlf-updates-failure
path: diagnostics
continue-on-error: true
- name: Prepare failure comment
id: failure-comment
shell: bash
env:
GENERATE_RESULT: ${{ needs.generate.result }}
APPLY_RESULT: ${{ needs.apply.result }}
run: |
run_url="${RUN_URL}"
reason=""
if [ -f diagnostics/failure_reason.txt ]; then
reason=$(tr -d '\r' < diagnostics/failure_reason.txt | tr -d '\000')
fi
message="❌ Failed to update XLF files."
if [ "${GENERATE_RESULT}" = "failure" ]; then
case "$reason" in
update)
message="$message The XLF update script failed."
;;
esac
elif [ "${APPLY_RESULT}" = "failure" ]; then
message="$message Applying or pushing the XLF changes failed."
fi
message="$message Please check [the workflow run](${run_url}) for details."
printf 'body<<EOF\n%s\nEOF\n' "$message" >> "$GITHUB_OUTPUT"
- name: Comment on PR - Failure
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENT_BODY: ${{ steps.failure-comment.outputs.body }}
with:
github-token: ${{ github.token }}
script: |
const { owner, repo } = context.repo;
const reactionCommentId = context.payload.comment.id;
await github.rest.issues.createComment({
owner,
repo,
issue_number: Number(context.payload.issue.number),
body: process.env.COMMENT_BODY,
});
if (reactionCommentId) {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: Number(reactionCommentId),
content: 'confused',
});
}