|
14 | 14 | required: true |
15 | 15 | type: boolean |
16 | 16 | default: true |
| 17 | + po_files: |
| 18 | + description: "Optional: space-separated .po paths to push (empty = every .po file)" |
| 19 | + required: false |
| 20 | + type: string |
| 21 | + default: "" |
17 | 22 | push_sources: |
18 | 23 | description: "Generate and push CPython source templates" |
19 | 24 | required: true |
|
43 | 48 | - name: Checkout translation repository |
44 | 49 | uses: actions/checkout@v4 |
45 | 50 | with: |
| 51 | + fetch-depth: 0 |
46 | 52 | persist-credentials: false |
47 | 53 |
|
48 | 54 | - name: Set up Python |
@@ -92,234 +98,195 @@ jobs: |
92 | 98 | --project-slug "$TX_PROJECT" \ |
93 | 99 | --version "$CPYTHON_VERSION" |
94 | 100 |
|
95 | | - - name: Validate Transifex configuration |
| 101 | + - name: Select translation files to push |
| 102 | + env: |
| 103 | + EVENT_NAME: ${{ github.event_name }} |
| 104 | + BEFORE_SHA: ${{ github.event.before }} |
| 105 | + AFTER_SHA: ${{ github.sha }} |
| 106 | + PO_FILES_INPUT: ${{ inputs.po_files }} |
96 | 107 | run: | |
97 | | - python - <<'PY' |
98 | | - import configparser |
99 | | - from pathlib import Path |
100 | | -
|
101 | | - path = Path(".tx/config") |
102 | | - config = configparser.ConfigParser(interpolation=None) |
103 | | -
|
104 | | - if not path.is_file() or not config.read(path): |
105 | | - raise SystemExit("ERROR: .tx/config was not generated") |
106 | | -
|
107 | | - expected = { |
108 | | - "builtins--constants", |
109 | | - "builtins--exceptions", |
110 | | - "builtins--functions", |
111 | | - "builtins--index", |
112 | | - "builtins--stdtypes", |
113 | | - "builtins--threadsafety", |
114 | | - "builtins--time-complexity", |
115 | | - } |
116 | | -
|
117 | | - resources = { |
118 | | - section.rsplit(":r:", 1)[-1]: section |
119 | | - for section in config.sections() |
120 | | - if ":r:" in section |
121 | | - } |
122 | | -
|
123 | | - missing = expected - resources.keys() |
124 | | -
|
125 | | - if missing: |
126 | | - raise SystemExit( |
127 | | - "ERROR: missing builtins resources: " |
128 | | - + ", ".join(sorted(missing)) |
129 | | - ) |
130 | | -
|
131 | | - for resource in sorted(expected): |
132 | | - section = resources[resource] |
133 | | - source = config.get( |
134 | | - section, "source_file", fallback="" |
135 | | - ).strip() |
136 | | -
|
137 | | - expected_source = ( |
138 | | - f"gettext/{resource.replace('--', '/')}.pot" |
139 | | - ) |
| 108 | + set -euo pipefail |
140 | 109 |
|
141 | | - if source != expected_source: |
142 | | - raise SystemExit( |
143 | | - f"ERROR: unexpected source_file for {resource}: " |
144 | | - f"{source}" |
145 | | - ) |
| 110 | + out="$RUNNER_TEMP/changed_po.txt" |
| 111 | + : > "$out" |
| 112 | +
|
| 113 | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then |
| 114 | + if [ -n "${PO_FILES_INPUT//[[:space:]]/}" ]; then |
| 115 | + read -ra requested <<< "$PO_FILES_INPUT" |
| 116 | + printf '%s\n' "${requested[@]}" > "$out" |
| 117 | + else |
| 118 | + git ls-files '*.po' > "$out" |
| 119 | + fi |
| 120 | + elif [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ] \ |
| 121 | + && git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then |
| 122 | + git diff --name-only --diff-filter=AMR "$BEFORE_SHA" "$AFTER_SHA" -- '*.po' > "$out" |
| 123 | + else |
| 124 | + echo "Previous commit unavailable; using only the triggering commit." |
| 125 | + git diff-tree --root --no-commit-id --name-only -r --diff-filter=AMR "$AFTER_SHA" -- '*.po' > "$out" |
| 126 | + fi |
146 | 127 |
|
147 | | - print( |
148 | | - "Transifex configuration and all seven builtins mappings validated." |
149 | | - ) |
150 | | - PY |
| 128 | + echo "Selected $(wc -l < "$out") .po file(s):" |
| 129 | + cat "$out" |
151 | 130 |
|
152 | | - - name: Diagnose builtins index resource |
| 131 | + - name: Stage selected translations for Transifex |
| 132 | + id: stage |
153 | 133 | run: | |
154 | 134 | python - <<'PY' |
155 | | - import ast |
156 | 135 | import configparser |
| 136 | + import os |
| 137 | + import re |
| 138 | + import shutil |
| 139 | + import sys |
157 | 140 | from pathlib import Path |
158 | 141 |
|
159 | | - config = configparser.ConfigParser(interpolation=None) |
160 | | -
|
161 | | - if not config.read(".tx/config"): |
162 | | - raise SystemExit("ERROR: Could not read .tx/config") |
163 | | -
|
164 | | - section = "o:python-doc:p:python-newest:r:builtins--index" |
165 | | -
|
166 | | - if not config.has_section(section): |
167 | | - raise SystemExit( |
168 | | - f"ERROR: missing Transifex resource: {section}" |
169 | | - ) |
170 | | -
|
171 | | - print("=== Generated Transifex mapping ===") |
172 | | -
|
173 | | - for key in ( |
174 | | - "file_filter", |
175 | | - "source_file", |
176 | | - "type", |
177 | | - "resource_name", |
178 | | - ): |
179 | | - print( |
180 | | - f"{key} = " |
181 | | - f"{config.get(section, key, fallback='<missing>')}" |
182 | | - ) |
| 142 | + language = os.environ["TX_LANGUAGE"] |
| 143 | + runner_temp = Path(os.environ["RUNNER_TEMP"]) |
| 144 | + stage_root = Path("_tx_stage") |
183 | 145 |
|
184 | | - po_path = Path("builtins/index.po") |
| 146 | + config_path = Path(".tx/config") |
| 147 | + config = configparser.ConfigParser(interpolation=None) |
185 | 148 |
|
186 | | - if not po_path.is_file(): |
187 | | - raise SystemExit( |
188 | | - f"ERROR: translation file not found: {po_path}" |
189 | | - ) |
| 149 | + if not config.read(config_path, encoding="utf-8"): |
| 150 | + sys.exit("ERROR: .tx/config was not generated") |
190 | 151 |
|
191 | | - print(f"\nTranslation file exists: {po_path}") |
| 152 | + # The generated file_filter has no <lang> placeholder, so the CLI |
| 153 | + # cannot map it to a language and silently skips the upload. |
| 154 | + by_file = {} |
192 | 155 |
|
193 | | - entries = [] |
194 | | - msgid = None |
195 | | - msgstr = "" |
196 | | - field = None |
| 156 | + for section in config.sections(): |
| 157 | + if ":r:" not in section: |
| 158 | + continue |
197 | 159 |
|
198 | | - for line in ( |
199 | | - po_path.read_text(encoding="utf-8").splitlines() + [""] |
200 | | - ): |
201 | | - if line.startswith("msgid "): |
202 | | - if msgid is not None: |
203 | | - entries.append((msgid, msgstr)) |
| 160 | + file_filter = config.get( |
| 161 | + section, "file_filter", fallback="" |
| 162 | + ).strip() |
204 | 163 |
|
205 | | - msgid = ast.literal_eval(line[6:]) |
206 | | - msgstr = "" |
207 | | - field = "msgid" |
| 164 | + if file_filter: |
| 165 | + by_file[os.path.normpath(file_filter)] = section |
208 | 166 |
|
209 | | - elif line.startswith("msgstr "): |
210 | | - msgstr = ast.literal_eval(line[7:]) |
211 | | - field = "msgstr" |
| 167 | + selected = [ |
| 168 | + line.strip() |
| 169 | + for line in (runner_temp / "changed_po.txt") |
| 170 | + .read_text(encoding="utf-8") |
| 171 | + .splitlines() |
| 172 | + if line.strip() |
| 173 | + ] |
212 | 174 |
|
213 | | - elif line.startswith('"') and field: |
214 | | - value = ast.literal_eval(line) |
| 175 | + section_re = re.compile(r"^o:([^:]+):p:([^:]+):r:(.+)$") |
| 176 | + staged = {} |
| 177 | + skipped = [] |
215 | 178 |
|
216 | | - if field == "msgid": |
217 | | - msgid += value |
218 | | - else: |
219 | | - msgstr += value |
| 179 | + for name in selected: |
| 180 | + rel = os.path.normpath(name) |
220 | 181 |
|
221 | | - elif not line.strip(): |
222 | | - if msgid is not None: |
223 | | - entries.append((msgid, msgstr)) |
| 182 | + if ( |
| 183 | + os.path.isabs(rel) |
| 184 | + or rel.startswith("..") |
| 185 | + or not rel.endswith(".po") |
| 186 | + ): |
| 187 | + sys.exit(f"ERROR: refusing unexpected path: {name!r}") |
224 | 188 |
|
225 | | - msgid = None |
226 | | - msgstr = "" |
227 | | - field = None |
| 189 | + section = by_file.get(rel) |
| 190 | + source = Path(rel) |
228 | 191 |
|
229 | | - translated = [ |
230 | | - (source, target) |
231 | | - for source, target in entries |
232 | | - if source and target.strip() |
233 | | - ] |
| 192 | + if section is None or not source.is_file(): |
| 193 | + skipped.append(rel) |
| 194 | + continue |
234 | 195 |
|
235 | | - print(f"Non-empty translated entries: {len(translated)}") |
| 196 | + target = stage_root / language / rel |
| 197 | + target.parent.mkdir(parents=True, exist_ok=True) |
| 198 | + shutil.copy2(source, target) |
236 | 199 |
|
237 | | - for source, target in translated: |
238 | | - print( |
239 | | - f" {source[:100]!r} -> {target[:100]!r}" |
240 | | - ) |
| 200 | + if target.read_bytes() != source.read_bytes(): |
| 201 | + sys.exit(f"ERROR: staged copy differs from {rel}") |
241 | 202 |
|
242 | | - if len(translated) < 5: |
243 | | - raise SystemExit( |
244 | | - "ERROR: expected at least five translated entries " |
245 | | - "in builtins/index.po" |
| 203 | + config.set( |
| 204 | + section, |
| 205 | + "file_filter", |
| 206 | + f"{stage_root.as_posix()}/<lang>/{Path(rel).as_posix()}", |
246 | 207 | ) |
247 | 208 |
|
248 | | - print("Builtins index diagnostic passed.") |
249 | | - PY |
| 209 | + match = section_re.match(section) |
250 | 210 |
|
251 | | - - name: Stage builtins index translation for Transifex |
252 | | - run: | |
253 | | - python - <<'PY' |
254 | | - import configparser |
255 | | - import shutil |
256 | | - from pathlib import Path |
| 211 | + if not match: |
| 212 | + sys.exit(f"ERROR: unexpected section name: {section}") |
257 | 213 |
|
258 | | - config_path = Path(".tx/config") |
259 | | - config = configparser.ConfigParser(interpolation=None) |
| 214 | + staged[section] = f"{match.group(2)}.{match.group(3)}" |
260 | 215 |
|
261 | | - if not config.read(config_path): |
262 | | - raise SystemExit("ERROR: Could not read .tx/config") |
| 216 | + # Keep only the resources being pushed, so `tx push` touches |
| 217 | + # nothing else. |
| 218 | + for section in list(config.sections()): |
| 219 | + if ":r:" in section and section not in staged: |
| 220 | + config.remove_section(section) |
263 | 221 |
|
264 | | - section = "o:python-doc:p:python-newest:r:builtins--index" |
265 | | -
|
266 | | - if not config.has_section(section): |
267 | | - raise SystemExit( |
268 | | - f"ERROR: Missing Transifex resource: {section}" |
| 222 | + for rel in skipped: |
| 223 | + print( |
| 224 | + f"::warning::No Transifex resource or file for {rel}; skipped" |
269 | 225 | ) |
270 | 226 |
|
271 | | - source = Path("builtins/index.po") |
272 | | - staged = Path("builtins/pa/index.po") |
| 227 | + if staged: |
| 228 | + with config_path.open("w", encoding="utf-8") as file: |
| 229 | + config.write(file) |
273 | 230 |
|
274 | | - if not source.is_file(): |
275 | | - raise SystemExit( |
276 | | - f"ERROR: Missing translation file: {source}" |
| 231 | + (runner_temp / "staged_resources.txt").write_text( |
| 232 | + "\n".join(sorted(staged.values())) + "\n", |
| 233 | + encoding="utf-8", |
277 | 234 | ) |
278 | 235 |
|
279 | | - staged.parent.mkdir(parents=True, exist_ok=True) |
280 | | - shutil.copy2(source, staged) |
281 | | -
|
282 | | - config.set( |
283 | | - section, |
284 | | - "file_filter", |
285 | | - "builtins/<lang>/index.po", |
286 | | - ) |
287 | | -
|
288 | | - with config_path.open("w", encoding="utf-8") as file: |
289 | | - config.write(file) |
290 | | -
|
291 | | - print(f"Staged Punjabi translation: {staged}") |
292 | | - print( |
293 | | - "Updated file_filter:", |
294 | | - config.get(section, "file_filter"), |
295 | | - ) |
296 | | -
|
297 | | - if not staged.is_file(): |
298 | | - raise SystemExit("ERROR: Staged translation was not created") |
| 236 | + print(f"Staged {len(staged)} resource(s):") |
299 | 237 |
|
300 | | - if staged.read_bytes() != source.read_bytes(): |
301 | | - raise SystemExit( |
302 | | - "ERROR: Staged translation differs from the original" |
303 | | - ) |
| 238 | + for resource in sorted(staged.values()): |
| 239 | + print(f" {resource}") |
304 | 240 |
|
305 | | - print("Staged translation validated successfully.") |
| 241 | + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as file: |
| 242 | + file.write(f"count={len(staged)}\n") |
306 | 243 | PY |
307 | 244 |
|
308 | 245 | - name: Push Punjabi translations |
| 246 | + if: steps.stage.outputs.count != '0' |
309 | 247 | env: |
310 | 248 | TX_TOKEN: ${{ secrets.TX_TOKEN }} |
311 | 249 | run: | |
| 250 | + set -euo pipefail |
| 251 | +
|
| 252 | + log="$RUNNER_TEMP/tx-push.log" |
| 253 | +
|
312 | 254 | /usr/local/bin/tx push \ |
313 | 255 | --translation \ |
314 | | - --languages "$TX_LANGUAGE" \ |
315 | | - --use-git-timestamps |
| 256 | + --languages "$TX_LANGUAGE" 2>&1 | tee "$log" |
| 257 | +
|
| 258 | + missing=0 |
| 259 | +
|
| 260 | + while IFS= read -r resource; do |
| 261 | + [ -n "$resource" ] || continue |
| 262 | +
|
| 263 | + if ! grep -F "$resource [$TX_LANGUAGE]" "$log" \ |
| 264 | + | grep -vi 'skipping' > /dev/null; then |
| 265 | + echo "::error::No upload confirmation for $resource [$TX_LANGUAGE]" |
| 266 | + missing=1 |
| 267 | + fi |
| 268 | + done < "$RUNNER_TEMP/staged_resources.txt" |
| 269 | +
|
| 270 | + exit "$missing" |
316 | 271 |
|
317 | | - - name: Report Transifex push status |
| 272 | + - name: Summarize Transifex push |
318 | 273 | if: always() |
319 | 274 | run: | |
320 | | - echo "Review the preceding Transifex CLI logs." |
321 | | - echo "Confirm that builtins--index was processed successfully." |
322 | | - echo "A successful command alone does not prove that remote translations changed." |
| 275 | + { |
| 276 | + echo "### Transifex push" |
| 277 | +
|
| 278 | + if [ -s "$RUNNER_TEMP/staged_resources.txt" ]; then |
| 279 | + echo "Pushed [$TX_LANGUAGE] translations for:" |
| 280 | + sed 's/^/- /' "$RUNNER_TEMP/staged_resources.txt" |
| 281 | + else |
| 282 | + echo "No translation files were pushed." |
| 283 | + fi |
| 284 | +
|
| 285 | + echo |
| 286 | + echo "A green run does not prove remote content changed; check the" |
| 287 | + echo "resource in Transifex. Pushed strings are not reviewed, and the" |
| 288 | + echo "nightly reviewed-only pull ignores unreviewed strings." |
| 289 | + } >> "$GITHUB_STEP_SUMMARY" |
323 | 290 |
|
324 | 291 | push-sources: |
325 | 292 | name: Generate and push source templates |
|
0 commit comments