Skip to content

Commit 27f39c7

Browse files
justinyooCopilot
andcommitted
Isolate localization generation and publishing
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 63ff875 commit 27f39c7

4 files changed

Lines changed: 272 additions & 12 deletions

File tree

‎.github/workflows/localization-sync.yml‎

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,23 @@ on:
1313
- 'workshop/**/*.md'
1414
workflow_dispatch:
1515

16-
permissions:
17-
contents: write
18-
pull-requests: write
19-
copilot-requests: write
16+
permissions: {}
2017

2118
concurrency:
2219
group: localization-sync-main
2320
cancel-in-progress: false
2421

2522
jobs:
26-
localize:
27-
name: Update localized documentation
23+
generate:
24+
name: Generate localized documentation
2825
runs-on: ubuntu-latest
2926
timeout-minutes: 45
27+
permissions:
28+
contents: read
29+
copilot-requests: write
30+
outputs:
31+
changed: ${{ steps.detect.outputs.changed }}
32+
changed_files: ${{ steps.detect.outputs.changed_files }}
3033
steps:
3134
- name: Checkout repository
3235
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
@@ -110,8 +113,56 @@ jobs:
110113
111114
rm .localization-changed-files.txt
112115
113-
- name: Guard generated changes
116+
- name: Upload generated localization tree
114117
if: steps.detect.outputs.changed == 'true'
118+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
119+
with:
120+
name: localization-output
121+
path: |
122+
localizations
123+
docs/locale-registry.js
124+
include-hidden-files: true
125+
if-no-files-found: error
126+
retention-days: 1
127+
128+
validate:
129+
name: Validate generated localization
130+
needs: generate
131+
if: needs.generate.outputs.changed == 'true'
132+
runs-on: ubuntu-latest
133+
timeout-minutes: 10
134+
permissions:
135+
contents: read
136+
outputs:
137+
changed: ${{ steps.generated.outputs.changed }}
138+
steps:
139+
- name: Checkout trusted repository
140+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
141+
with:
142+
persist-credentials: false
143+
144+
- name: Set up Node.js
145+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
146+
with:
147+
node-version: 22
148+
149+
- name: Set up Python
150+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
151+
with:
152+
python-version: "3.11"
153+
154+
- name: Download generated localization tree
155+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
156+
with:
157+
name: localization-output
158+
path: ${{ runner.temp }}/localization-output
159+
160+
- name: Materialize generated localization tree
161+
run: |
162+
python3 scripts/materialize_localization_artifact.py \
163+
"${{ runner.temp }}/localization-output"
164+
165+
- name: Guard generated changes
115166
id: generated
116167
run: |
117168
python3 scripts/check_localization_updates.py \
@@ -129,15 +180,47 @@ jobs:
129180
130181
- name: Validate localized site
131182
if: steps.generated.outputs.changed == 'true'
183+
run: bash scripts/validate-workshop.sh content
184+
185+
publish:
186+
name: Publish localization pull request
187+
needs: [generate, validate]
188+
if: needs.validate.outputs.changed == 'true'
189+
runs-on: ubuntu-latest
190+
timeout-minutes: 10
191+
permissions:
192+
contents: write
193+
pull-requests: write
194+
steps:
195+
- name: Checkout trusted repository
196+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
197+
with:
198+
fetch-depth: 0
199+
persist-credentials: false
200+
201+
- name: Set up Python
202+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
203+
with:
204+
python-version: "3.11"
205+
206+
- name: Download validated localization tree
207+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
208+
with:
209+
name: localization-output
210+
path: ${{ runner.temp }}/localization-output
211+
212+
- name: Materialize validated localization tree
132213
run: |
133-
node docs/tests/workshop-site.test.js
134-
python3 scripts/validate_workshop.py
214+
python3 scripts/materialize_localization_artifact.py \
215+
"${{ runner.temp }}/localization-output"
135216
136217
- name: Create localization review pull request
137-
if: steps.generated.outputs.changed == 'true'
138218
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
139219
with:
140220
token: ${{ github.token }}
221+
add-paths: |
222+
localizations
223+
docs/locale-registry.js
141224
branch: automation/localization-sync
142225
base: main
143226
delete-branch: true
@@ -147,7 +230,7 @@ jobs:
147230
GitHub Copilot CLI ran the repository-local localization skill for these English sources requiring synchronization:
148231
149232
```
150-
${{ steps.detect.outputs.changed_files }}
233+
${{ needs.generate.outputs.changed_files }}
151234
```
152235
153236
This pull request updates only existing locales. Please review translation quality and the localization baselines before merging.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env python3
2+
"""Safely copy generated localization outputs into a trusted checkout."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import os
8+
import shutil
9+
from pathlib import Path
10+
11+
ROOT = Path(__file__).resolve().parent.parent
12+
REGISTRY_PATH = Path("docs/locale-registry.js")
13+
14+
15+
def validate_artifact(artifact_root: Path) -> None:
16+
localization_root = artifact_root / "localizations"
17+
registry_path = artifact_root / REGISTRY_PATH
18+
19+
if not localization_root.is_dir() or localization_root.is_symlink():
20+
raise RuntimeError("Artifact must contain a regular localizations directory.")
21+
if not registry_path.is_file() or registry_path.is_symlink():
22+
raise RuntimeError(f"Artifact must contain a regular {REGISTRY_PATH} file.")
23+
24+
for directory, directory_names, file_names in os.walk(
25+
artifact_root,
26+
followlinks=False,
27+
):
28+
directory_path = Path(directory)
29+
for name in directory_names:
30+
path = directory_path / name
31+
if path.is_symlink():
32+
raise RuntimeError(f"Artifact contains a symbolic link: {path}")
33+
relative_path = path.relative_to(artifact_root)
34+
if (
35+
relative_path != Path("docs")
36+
and relative_path.parts[0] != "localizations"
37+
):
38+
raise RuntimeError(f"Artifact contains an unexpected path: {relative_path}")
39+
for name in file_names:
40+
path = directory_path / name
41+
if path.is_symlink() or not path.is_file():
42+
raise RuntimeError(f"Artifact contains a non-regular file: {path}")
43+
44+
relative_path = path.relative_to(artifact_root)
45+
if (
46+
relative_path != REGISTRY_PATH
47+
and relative_path.parts[0] != "localizations"
48+
):
49+
raise RuntimeError(f"Artifact contains an unexpected path: {relative_path}")
50+
51+
52+
def materialize(artifact_root: Path, repository_root: Path = ROOT) -> None:
53+
artifact_root = artifact_root.resolve()
54+
repository_root = repository_root.resolve()
55+
validate_artifact(artifact_root)
56+
57+
target_localizations = repository_root / "localizations"
58+
if target_localizations.exists():
59+
shutil.rmtree(target_localizations)
60+
shutil.copytree(artifact_root / "localizations", target_localizations)
61+
62+
target_registry = repository_root / REGISTRY_PATH
63+
target_registry.parent.mkdir(parents=True, exist_ok=True)
64+
shutil.copy2(artifact_root / REGISTRY_PATH, target_registry)
65+
66+
67+
def parse_args() -> argparse.Namespace:
68+
parser = argparse.ArgumentParser()
69+
parser.add_argument(
70+
"artifact_root",
71+
type=Path,
72+
help="Directory containing localizations/ and docs/locale-registry.js.",
73+
)
74+
return parser.parse_args()
75+
76+
77+
def main() -> int:
78+
args = parse_args()
79+
materialize(args.artifact_root)
80+
print("Materialized trusted localization output paths.")
81+
return 0
82+
83+
84+
if __name__ == "__main__":
85+
raise SystemExit(main())
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import importlib.util
6+
import tempfile
7+
import unittest
8+
from pathlib import Path
9+
10+
11+
SCRIPT = Path(__file__).with_name("materialize_localization_artifact.py")
12+
SPEC = importlib.util.spec_from_file_location("materialize_localization_artifact", SCRIPT)
13+
assert SPEC and SPEC.loader
14+
MODULE = importlib.util.module_from_spec(SPEC)
15+
SPEC.loader.exec_module(MODULE)
16+
17+
18+
class MaterializeLocalizationArtifactTests(unittest.TestCase):
19+
def setUp(self) -> None:
20+
self.temp_dir = tempfile.TemporaryDirectory()
21+
self.root = Path(self.temp_dir.name)
22+
self.artifact = self.root / "artifact"
23+
self.repository = self.root / "repository"
24+
(self.artifact / "localizations" / "ko-kr").mkdir(parents=True)
25+
(self.artifact / "docs").mkdir()
26+
self.repository.mkdir()
27+
28+
(self.artifact / "localizations" / "ko-kr" / ".localization-state.json").write_text(
29+
'{"version": 1, "files": {}}\n',
30+
encoding="utf-8",
31+
)
32+
(self.artifact / "localizations" / "ko-kr" / "README.md").write_text(
33+
"새 문서\n",
34+
encoding="utf-8",
35+
)
36+
(self.artifact / "docs" / "locale-registry.js").write_text(
37+
"const locale = 'ko-kr';\n",
38+
encoding="utf-8",
39+
)
40+
41+
def tearDown(self) -> None:
42+
self.temp_dir.cleanup()
43+
44+
def test_materializes_only_expected_outputs(self) -> None:
45+
(self.repository / "localizations" / "old").mkdir(parents=True)
46+
(self.repository / "localizations" / "old" / "README.md").write_text(
47+
"obsolete\n",
48+
encoding="utf-8",
49+
)
50+
51+
MODULE.materialize(self.artifact, self.repository)
52+
53+
self.assertFalse((self.repository / "localizations" / "old").exists())
54+
self.assertEqual(
55+
(self.repository / "localizations" / "ko-kr" / "README.md").read_text(
56+
encoding="utf-8"
57+
),
58+
"새 문서\n",
59+
)
60+
self.assertEqual(
61+
(self.repository / "docs" / "locale-registry.js").read_text(
62+
encoding="utf-8"
63+
),
64+
"const locale = 'ko-kr';\n",
65+
)
66+
67+
def test_rejects_unexpected_files_before_modifying_checkout(self) -> None:
68+
unexpected = self.artifact / "scripts" / "validate_workshop.py"
69+
unexpected.parent.mkdir()
70+
unexpected.write_text("malicious\n", encoding="utf-8")
71+
existing = self.repository / "localizations" / "ko-kr" / "README.md"
72+
existing.parent.mkdir(parents=True)
73+
existing.write_text("keep\n", encoding="utf-8")
74+
75+
with self.assertRaisesRegex(RuntimeError, "unexpected path"):
76+
MODULE.materialize(self.artifact, self.repository)
77+
78+
self.assertEqual(existing.read_text(encoding="utf-8"), "keep\n")
79+
80+
def test_rejects_symbolic_links(self) -> None:
81+
link = self.artifact / "localizations" / "ko-kr" / "linked.md"
82+
try:
83+
link.symlink_to(self.artifact / "docs" / "locale-registry.js")
84+
except OSError as error:
85+
self.skipTest(f"Symbolic links are unavailable: {error}")
86+
87+
with self.assertRaisesRegex(RuntimeError, "symbolic link|non-regular file"):
88+
MODULE.materialize(self.artifact, self.repository)
89+
90+
91+
if __name__ == "__main__":
92+
unittest.main()

‎scripts/validate-workshop.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ run_system_python() {
3636

3737
validate_content() {
3838
run_system_python scripts/validate_workshop.py
39-
run_system_python -m unittest scripts/test_check_localization_updates.py
39+
run_system_python -m unittest discover -s scripts -p 'test_*.py'
4040
node docs/tests/markdown-language-preprocessor.test.js
4141
node docs/tests/workshop-site.test.js
4242
node --test docs/tests/workshop-completion.test.js

0 commit comments

Comments
 (0)