-
Notifications
You must be signed in to change notification settings - Fork 0
188 lines (182 loc) · 8.82 KB
/
Copy pathtranslation-issue.yml
File metadata and controls
188 lines (182 loc) · 8.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
#############################################################################################
# Translation issue bot
#
# Purpose: Community contributions come in as issues, not PRs. This workflow:
# 1. On opened/edited: validate the issue body, post a preview comment.
# 2. On labeled `approved` (by a maintainer): apply the translation to the
# target locale file, commit it, close the issue.
#
# The `approved` label is only acted on if the labeler has write/maintain/admin
# permission on this repo, verified via the GitHub API at the top of the apply
# job. (`github.event.sender.author_association` is NOT populated on the issues
# event: `author_association` lives on Issue/Comment/Review entities, not on
# the simple-user `sender`, so an `if:`-based check would silently always be
# false and no apply would ever fire.)
#
# Comments and the apply commit both authenticate through the Saki-Yuzishima
# App installation token, so the bot shows up as `saki-yuzishima[bot]` rather
# than the generic `github-actions[bot]`. The apply commit is AUTHORED by the
# issue contributor (committer stays the bot), so translators appear in the
# contributors graph and get profile credit for their work. Requires org secrets
# CIDER_I18N_BOT_APP_ID and CIDER_I18N_BOT_PRIVATE_KEY, and the App must have
# `Contents: write` and `Issues: write` granted on this repo.
#############################################################################################
name: Translation issue
on:
issues:
types: [opened, edited, labeled]
# Least-privilege at the workflow level. Individual jobs widen as needed.
# Nothing here ever runs on pull_request, so even if a malicious PR landed,
# this workflow's secrets would not be reachable from that PR's code.
permissions: {}
jobs:
validate:
if: |
contains(github.event.issue.labels.*.name, 'translation') &&
(github.event.action == 'opened' || github.event.action == 'edited')
name: Validate and preview
runs-on: ubuntu-latest
# No GITHUB_TOKEN usage in this job: comments post under the App token.
permissions: {}
steps:
- name: 🪪 Mint App installation token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CIDER_I18N_BOT_APP_ID }}
private-key: ${{ secrets.CIDER_I18N_BOT_PRIVATE_KEY }}
permission-issues: write
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- run: npm install --no-package-lock
- name: Run linter
env:
# Pass the App token as GITHUB_TOKEN so the preview comment posts
# as saki-yuzishima[bot] instead of the generic github-actions[bot].
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
ISSUE_BODY: ${{ github.event.issue.body }}
# 'opened' or 'edited': the linter uses this to phrase the comment
# differently and to upsert (edit) its own previous comment rather
# than posting a new one for every edit.
ISSUE_ACTION: ${{ github.event.action }}
MODE: validate
run: node scripts/lint-translation-issue.mjs
apply:
if: |
github.event.action == 'labeled' &&
github.event.label.name == 'approved' &&
contains(github.event.issue.labels.*.name, 'translation')
name: Apply approved translation
runs-on: ubuntu-latest
# GITHUB_TOKEN is used only by the Verify step (collaborator-permission
# lookup needs metadata:read, which is granted implicitly). The actual
# apply, push, and comment all authenticate via the App token.
permissions: {}
# Serialize approvals so two simultaneous `approved` labels can't race
# against each other when writing the same locale file. Don't cancel:
# losing an approved contribution mid-write would be worse than waiting.
concurrency:
group: translation-issue-apply
cancel-in-progress: false
steps:
- name: 🛡️ Verify approver has write access
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
perm=$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${GITHUB_ACTOR}/permission" --jq .permission)
case "$perm" in
admin|maintain|write)
echo "✓ @${GITHUB_ACTOR} has $perm; proceeding."
;;
*)
echo "::error::@${GITHUB_ACTOR} cannot apply translations (permission: $perm). Only repo members with write/maintain/admin may approve."
exit 1
;;
esac
- name: 🪪 Mint App installation token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CIDER_I18N_BOT_APP_ID }}
private-key: ${{ secrets.CIDER_I18N_BOT_PRIVATE_KEY }}
permission-contents: write
permission-issues: write
- uses: actions/checkout@v4
with:
# The push authenticates explicitly with the App token below.
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: "24"
- run: npm install --no-package-lock
- name: Apply to locale file
env:
# App token so the success comment + close-issue both come from
# saki-yuzishima[bot].
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_ACTION: ${{ github.event.action }}
MODE: apply
run: node scripts/lint-translation-issue.mjs
- name: Update credits and badges
run: node scripts/update-credits.mjs
- name: Commit and push
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
ISSUE_AUTHOR_ID: ${{ github.event.issue.user.id }}
run: |
set -euo pipefail
# Stage first, then diff the index. A first-ever contribution for a
# language creates a new untracked file, which `git diff` alone
# would not see. Badges and the README table are regenerated by the
# previous step and ride along in the same commit.
git add locales/ .github/badges/ README.md
if git diff --cached --quiet; then
echo "No file changes after apply. Was the issue already applied?"
exit 0
fi
git config user.name 'saki-yuzishima[bot]'
git config user.email '286499753+saki-yuzishima[bot]@users.noreply.github.com'
# Author = the contributor, so GitHub's contributors graph and the
# commit byline credit the human; committer stays the bot. The
# id+login noreply address keeps the attribution attached to the
# account even if the user later renames. (Values come in via env,
# not inline workflow expressions, so nothing user-controlled
# touches the script source; logins are [A-Za-z0-9-] and the apply
# step has already hard-failed on anything weirder via AUTHOR_RE.)
git commit \
--author="${ISSUE_AUTHOR} <${ISSUE_AUTHOR_ID}+${ISSUE_AUTHOR}@users.noreply.github.com>" \
-m "feat(i18n): apply translation from #${ISSUE_NUMBER}"
# Push with rebase-retry. The job-level concurrency group serializes
# apply runs against each other, but cross-workflow pushes (ai-fill
# commits, en-US mirror from Citadel) can still land between this
# checkout and our push, causing a non-fast-forward rejection. On
# rejection, fetch the new tip, rebase our commit on top, retry.
# Token explicit in the URL: does not depend on stored credentials.
REMOTE="https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
for attempt in 1 2 3 4 5; do
if git push "$REMOTE" HEAD:main; then
echo "push succeeded (attempt $attempt)"
exit 0
fi
echo "::warning::push attempt $attempt rejected (likely concurrent push). Rebasing onto origin/main..."
git fetch "$REMOTE" main
if ! git rebase FETCH_HEAD; then
echo "::error::rebase conflicts the bot can't resolve. A maintainer can re-apply the 'approved' label to retry from a fresh checkout."
git rebase --abort || true
exit 1
fi
# Jittered backoff so concurrent retries don't synchronize.
sleep $((attempt + RANDOM % 3))
done
echo "::error::push failed after 5 attempts; the remote ref keeps moving."
exit 1