Skip to content

Commit c2d858e

Browse files
committed
ci: stage every changed translation for Transifex push
1 parent f5d85c5 commit c2d858e

1 file changed

Lines changed: 147 additions & 180 deletions

File tree

‎.github/workflows/transifex-push.yml‎

Lines changed: 147 additions & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ on:
1414
required: true
1515
type: boolean
1616
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: ""
1722
push_sources:
1823
description: "Generate and push CPython source templates"
1924
required: true
@@ -43,6 +48,7 @@ jobs:
4348
- name: Checkout translation repository
4449
uses: actions/checkout@v4
4550
with:
51+
fetch-depth: 0
4652
persist-credentials: false
4753

4854
- name: Set up Python
@@ -92,234 +98,195 @@ jobs:
9298
--project-slug "$TX_PROJECT" \
9399
--version "$CPYTHON_VERSION"
94100
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 }}
96107
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
140109
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
146127
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"
151130
152-
- name: Diagnose builtins index resource
131+
- name: Stage selected translations for Transifex
132+
id: stage
153133
run: |
154134
python - <<'PY'
155-
import ast
156135
import configparser
136+
import os
137+
import re
138+
import shutil
139+
import sys
157140
from pathlib import Path
158141
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")
183145
184-
po_path = Path("builtins/index.po")
146+
config_path = Path(".tx/config")
147+
config = configparser.ConfigParser(interpolation=None)
185148
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")
190151
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 = {}
192155
193-
entries = []
194-
msgid = None
195-
msgstr = ""
196-
field = None
156+
for section in config.sections():
157+
if ":r:" not in section:
158+
continue
197159
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()
204163
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
208166
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+
]
212174
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 = []
215178
216-
if field == "msgid":
217-
msgid += value
218-
else:
219-
msgstr += value
179+
for name in selected:
180+
rel = os.path.normpath(name)
220181
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}")
224188
225-
msgid = None
226-
msgstr = ""
227-
field = None
189+
section = by_file.get(rel)
190+
source = Path(rel)
228191
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
234195
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)
236199
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}")
241202
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()}",
246207
)
247208
248-
print("Builtins index diagnostic passed.")
249-
PY
209+
match = section_re.match(section)
250210
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}")
257213
258-
config_path = Path(".tx/config")
259-
config = configparser.ConfigParser(interpolation=None)
214+
staged[section] = f"{match.group(2)}.{match.group(3)}"
260215
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)
263221
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"
269225
)
270226
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)
273230
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",
277234
)
278235
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):")
299237
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}")
304240
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")
306243
PY
307244
308245
- name: Push Punjabi translations
246+
if: steps.stage.outputs.count != '0'
309247
env:
310248
TX_TOKEN: ${{ secrets.TX_TOKEN }}
311249
run: |
250+
set -euo pipefail
251+
252+
log="$RUNNER_TEMP/tx-push.log"
253+
312254
/usr/local/bin/tx push \
313255
--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"
316271
317-
- name: Report Transifex push status
272+
- name: Summarize Transifex push
318273
if: always()
319274
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"
323290
324291
push-sources:
325292
name: Generate and push source templates

0 commit comments

Comments
 (0)