@samitouri / QOSAMI-WSL / commits / 1d6133f9

Localize ADMX Group Policy strings via Touchdown (#40515)

Add the WSL ADMX policy strings to the nightly Touchdown localization pipeline so translated .adml files are produced for every locale already covered by Resources.resw. Translated files land at intune/<locale>/WSL.adml, matching the Group Policy on-disk layout (the language-neutral WSL.admx stays at intune/WSL.admx). Changes: * intune/en-US/WSL.adml: new baseline ADML file. Each translatable string is annotated with {Locked="..."} XML comments so translators preserve product names (Windows Subsystem for Linux, WSL, WSL1, WSL2, Store WSL), .wslconfig keys (wsl2.kernel, wsl2.systemDistro, wsl2.kernelCommandLine, wsl2.nestedVirtualization, wsl2.kernelDebugPort, wsl2.networkingmode, wsl2.firewall), command names (wsl.exe, debug-shell, mount), networking-mode enum values (None, NAT, Mirrored, VirtioProxy), and the policy state literal Disabled. * .pipelines/wsl-build-nightly-localization.yml: add intune/en-US/WSL.adml to the trigger paths and to the TouchdownBuildTask resourceFilePath block. The combined ';O:intune\' mapping puts translated files at intune/<locale>/WSL.adml. * tools/devops/validate-localization.py: add validate_adml() which enforces the same locked-token invariant as .resw (every {Locked="X"} token must appear verbatim in its target string value), validates that translated locales have the same string ids as the en-US baseline, and is parameterized by the ADML folder name. The argv shortcut used in CI to avoid a pip install dependency on click now accepts both 3 args (back-compat) and 4 args (with explicit ADML folder). * .pipelines/build-stage.yml: pass 'intune' explicitly to validate-localization.py so the validation invocation reads 'localization/strings en-US intune' -- both folders visible at the call site, consistent with the rest of the script's parameterization. * tools/devops/create-change.py: include untracked files in the 'Changed files:' report and stage them with 'git add -A' (via repo.git.add(A=True)) before commit. Without this, Touchdown's first-time ADML outputs land as untracked files in newly created intune/<locale>/ directories and are silently dropped by 'git commit -a', which only stages modifications and deletions of already-tracked files. Confirmed against ADO build 146781281: Touchdown returned WSL.adml translations for 20 locales and saved them on disk, but the resulting auto-PR (#40511) contained only the resw modifications. Co-authored-by: Ben Hillis <benhill@ntdev.microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Ben Hillis committed May 13, 2026 at 12:40 UTC 1d6133f96fbc15f81e7780fe1a75fee96c194b0a
5 files changed +148 -12
.pipelines/build-stage.yml
+1 -1
@@ -104,7 +104,7 @@ stages:
104 pool: ${{ parameters.pool }}
105
106 steps:
107 - - script: python tools/devops/validate-localization.py localization/strings en-US
107 + - script: python tools/devops/validate-localization.py
108 displayName: Validate localization resources
109
110 - script: python tools\devops\validate-copyright-headers.py src
.pipelines/wsl-build-nightly-localization.yml
+2
@@ -5,6 +5,7 @@ trigger:
5 paths:
6 include:
7 - 'localization/strings/en-US/Resources.resw'
8 + - 'intune/en-US/WSL.adml'
9
10 # Schedule nightly build
11 # Cron syntax: "mm HH DD MM DW" = minutes, hours, days, months, day of week (UTC time)
@@ -44,6 +45,7 @@ steps:
45 resourceFilePath: |
46 localization\strings\en-US\Resources.resw;O:localization\strings\
47 storebroker\PDPs\en-us\PDP.xml;O:storebroker\PDPs\
48 + intune\en-US\WSL.adml;O:intune\
49 localizationTarget: true
50 pseudoSetting: 'Excluded'
51 cultureMappingType: 'None'
intune/en-US/WSL.adml
+27 -1
@@ -5,57 +5,83 @@
5 <description>Windows Subsystem for Linux</description>
6 <resources>
7 <stringTable>
8 + <!-- {Locked="Windows Subsystem for Linux"} -->
9 <string id="WindowsSubsystemForLinux">Windows Subsystem for Linux</string>
10
11 + <!-- {Locked="Windows Subsystem for Linux"} -->
12 <string id="AllowWSL">Allow the Windows Subsystem for Linux</string>
13 + <!-- {Locked="Windows Subsystem for Linux"} -->
14 <string id="AllowWSLExplain">When set to disabled, this policy disables access to running Linux distributions in the Windows Subsystem for Linux for all users on the machine.</string>
15
16 + <!-- {Locked="Windows Subsystem for Linux"} -->
17 <string id="AllowInboxWSL">Allow the Inbox version of the Windows Subsystem for Linux</string>
18 + <!-- {Locked="Windows Subsystem for Linux"}{Locked="WSL"} -->
19 <string id="AllowInboxWSLExplain">When set to disabled, this policy disables the inbox version (optional component) of the Windows Subsystem for Linux. If this policy is disabled, only the store version of WSL can be used.</string>
20
21 + <!-- {Locked="WSL1"} -->
22 <string id="AllowWSL1">Allow WSL1</string>
23 + <!-- {Locked="WSL1"}{Locked="WSL2"} -->
24 <string id="AllowWSL1Explain">When set to disabled, this policy disables WSL1. When disabled, only WSL2 distributions can be used.</string>
25
26 <string id="CustomKernelUserSettingConfigurable">Allow custom kernel configuration</string>
27 + <!-- {Locked=".wslconfig"}{Locked="wsl2.kernel"}{Locked="Store WSL"} -->
28 <string id="CustomKernelExplain">When set to disabled, this policy disables custom kernel configuration via .wslconfig (wsl2.kernel). This policy only applies to Store WSL.</string>
29
30 <string id="CustomSystemDistroUserSettingConfigurable">Allow custom system distribution configuration</string>
31 + <!-- {Locked=".wslconfig"}{Locked="wsl2.systemDistro"}{Locked="Store WSL"} -->
32 <string id="CustomSystemDistroExplain">When set to disabled, this policy disables custom system distribution configuration via .wslconfig (wsl2.systemDistro). This policy only applies to Store WSL.</string>
33
34 <string id="CustomKernelCommandLineUserSettingConfigurable">Allow kernel command line configuration</string>
35 + <!-- {Locked=".wslconfig"}{Locked="wsl2.kernelCommandLine"}{Locked="Store WSL"} -->
36 <string id="CustomKernelCommandLineExplain">When set to disabled, this policy disables kernel command line configuration via .wslconfig (wsl2.kernelCommandLine). This policy only applies to Store WSL.</string>
37
38 <string id="AllowDebugShell">Allow the debug shell</string>
39 + <!-- {Locked="wsl.exe"}{Locked="debug-shell"}{Locked="Store WSL"} -->
40 <string id="AllowDebugShellExplain">When set to disabled, this policy disables the debug shell (wsl.exe --debug-shell). This policy only applies to Store WSL.</string>
41
42 <string id="NestedVirtualizationUserSettingConfigurable">Allow nested virtualization</string>
43 + <!-- {Locked=".wslconfig"}{Locked="wsl2.nestedVirtualization"}{Locked="Store WSL"} -->
44 <string id="NestedVirtualizationExplain">When set to disabled, this policy disables nested virtualization configuration via .wslconfig (wsl2.nestedVirtualization). This policy only applies to Store WSL.</string>
45
46 <string id="KernelDebugUserSettingConfigurable">Allow kernel debugging</string>
47 + <!-- {Locked=".wslconfig"}{Locked="wsl2.kernelDebugPort"}{Locked="Store WSL"} -->
48 <string id="KernelDebugExplain">When set to disabled, this policy disables kernel debugging configuration via .wslconfig (wsl2.kernelDebugPort). This policy only applies to Store WSL.</string>
49
50 <string id="CustomNetworkingUserSettingConfigurable">Allow custom networking configuration</string>
38 - <string id="CustomNetworkingExplain">When set to disabled, this policy disables custom networking configuration via .wslconfig (wsl2.networkingmode). This policy only applies to Store WSL.</string>
51 + <!-- {Locked=".wslconfig"}{Locked="wsl2.networkingMode"}{Locked="Store WSL"} -->
52 + <string id="CustomNetworkingExplain">When set to disabled, this policy disables custom networking configuration via .wslconfig (wsl2.networkingMode). This policy only applies to Store WSL.</string>
53
54 <string id="FirewallUserSettingConfigurable">Allow user setting firewall configuration</string>
55 + <!-- {Locked=".wslconfig"}{Locked="wsl2.firewall"}{Locked="Store WSL"} -->
56 <string id="FirewallExplain">When set to disabled, this policy disables firewall configuration via .wslconfig (wsl2.firewall). This policy only applies to Store WSL.</string>
57
58 <string id="AllowDiskMount">Allow passthrough disk mount</string>
59 + <!-- {Locked="WSL2"}{Locked="wsl.exe"}{Locked="mount"}{Locked="Store WSL"} -->
60 <string id="AllowDiskMountExplain">When set to disabled, this policy disables passthrough disk mounting in WSL2 (wsl.exe --mount). This policy only applies to Store WSL.</string>
61
62 <string id="DefaultNetworkingMode">Configure default networking mode</string>
63 + <!-- {Locked="WSL2"} -->
64 <string id="DefaultNetworkingModeExplain">This policy specifies the default networking mode to be used for WSL2.</string>
65 + <!-- {Locked="None"} -->
66 <string id="NetworkingModeNone">None</string>
67 + <!-- {Locked="NAT"} -->
68 <string id="NetworkingModeNAT">NAT</string>
69 + <!-- {Locked="Mirrored"} -->
70 <string id="NetworkingModeMirrored">Mirrored</string>
71 + <!-- {Locked="VirtioProxy"} -->
72 <string id="NetworkingModeVirtioProxy">VirtioProxy</string>
73
74 + <!-- {Locked="WSL"} -->
75 <string id="WSLContainer">WSL container</string>
76
77 + <!-- {Locked="WSL"} -->
78 <string id="AllowWSLContainer">Allow WSL container</string>
79 + <!-- {Locked="WSL"}{Locked="Disabled"} -->
80 <string id="AllowWSLContainerExplain">This policy controls whether WSL container can be used on this machine. When enabled or not configured, users and Windows applications can run Linux containers via WSL. When set to disabled, WSL container is blocked for all users and Windows apps cannot run Linux containers. Warning: Setting this to 'Disabled' can break Windows apps that depend on Linux containers.</string>
81
82 + <!-- {Locked="WSL"} -->
83 <string id="WSLContainerRegistryAllowlist">Allowlist for WSL container registries</string>
84 + <!-- {Locked="WSL"} -->
85 <string id="WSLContainerRegistryAllowlistExplain">When enabled, WSL container will only be allowed to pull images from the registries listed here. This affects both the WSL container CLI and all applications using the WSL container API.</string>
86 </stringTable>
87 <presentationTable>
tools/devops/create-change.py
+6 -2
@@ -18,7 +18,9 @@ def main(repo_path: str, token: str, committer: str, message: str, branch: str,
18 try:
19 repo = Repo(repo_path)
20
21 - changed_files = [e.a_path for e in repo.index.diff(None)]
21 + modified_files = [e.a_path for e in repo.index.diff(None)]
22 + untracked_files = list(repo.untracked_files)
23 + changed_files = modified_files + untracked_files
24
25 if not changed_files:
26 print('No files changed, skipping')
@@ -33,7 +35,9 @@ def main(repo_path: str, token: str, committer: str, message: str, branch: str,
35 config.set_value("user", "email", COMMITTER_EMAIL)
36 config.set_value("user", "name", committer)
37
36 - repo.git.commit('-a', m=message)
38 + # 'git add -A' so newly created files in new directories are staged too.
39 + repo.git.add(A=True)
40 + repo.git.commit(m=message)
41 repo.git.push('origin', branch)
42
43 headers = {'Accept': 'application/vnd.github+json', 'Authorization': 'Bearer ' + token}
tools/devops/validate-localization.py
+112 -8
@@ -174,7 +174,108 @@ def fix_comments(comments: dict, path: str, strings: dict):
174 click.secho(f'Updated file: {path}. {missed} comments need manual changes', fg='green' if missed == 0 else 'yellow', bold=True)
175
176
177 -def run(resource_folder: str, baseline_language: str, fix: bool):
177 +ADML_NS = '{http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions}'
178 +RESOURCE_FOLDER = 'localization/strings'
179 +BASELINE_LANGUAGE = 'en-US'
180 +ADML_FOLDER = 'intune'
181 +ADML_FILENAME = 'WSL.adml'
182 +
183 +def get_adml_entries(path: str) -> tuple[dict, set]:
184 + """Parse an .adml file.
185 +
186 + Returns ({string_id: (value, [locked_tokens])}, {presentation_id, ...}).
187 + Locked tokens are extracted from XML comments of the form
188 + `<!-- {Locked="..."}{Locked="..."} -->` placed immediately before a
189 + `<string>` element. Non-Locked comments are ignored.
190 + """
191 + # Parse with a TreeBuilder that preserves comments so we can associate
192 + # {Locked="..."} tokens with the <string> element that follows them.
193 + parser = xml.etree.ElementTree.XMLParser(
194 + target=xml.etree.ElementTree.TreeBuilder(insert_comments=True))
195 + root = xml.etree.ElementTree.parse(path, parser=parser).getroot()
196 +
197 + string_table = root.find(f'.//{ADML_NS}stringTable')
198 + if string_table is None:
199 + raise RuntimeError(f'error: {path} is missing the required <stringTable> element')
200 +
201 + strings = {}
202 + pending_tokens = []
203 + for child in string_table:
204 + if child.tag is xml.etree.ElementTree.Comment:
205 + pending_tokens.extend(re.findall(r'\{Locked="([^"]*)"\}', child.text or ''))
206 + elif child.tag == f'{ADML_NS}string':
207 + sid = child.get('id')
208 + if sid is not None:
209 + strings[sid] = (child.text or '', pending_tokens)
210 + pending_tokens = []
211 + else:
212 + pending_tokens = []
213 +
214 + presentation_table = root.find(f'.//{ADML_NS}presentationTable')
215 + if presentation_table is None:
216 + raise RuntimeError(f'error: {path} is missing the required <presentationTable> element')
217 +
218 + presentations = {p.get('id') for p in presentation_table.findall(f'{ADML_NS}presentation') if p.get('id')}
219 +
220 + return strings, presentations
221 +
222 +def validate_adml(adml_folder: str, baseline_language: str) -> bool:
223 + baseline_path = f'{adml_folder}/{baseline_language}/{ADML_FILENAME}'
224 + if not os.path.isfile(baseline_path):
225 + print(f'info: ADML baseline not found at {baseline_path}, skipping ADML validation')
226 + return True
227 +
228 + print(f'Validating ADML baseline {baseline_path}')
229 + baseline, baseline_presentations = get_adml_entries(baseline_path)
230 + baseline_ids = set(baseline.keys())
231 +
232 + result = True
233 + for sid, (value, tokens) in baseline.items():
234 + for tok in tokens:
235 + if tok not in value:
236 + print(f'error: locked token "{tok}" not found in baseline ADML string {sid}: {value}')
237 + result = False
238 +
239 + if not os.path.isdir(adml_folder):
240 + return result
241 +
242 + for entry in sorted(os.listdir(adml_folder)):
243 + locale_path = f'{adml_folder}/{entry}/{ADML_FILENAME}'
244 + if entry == baseline_language or not os.path.isfile(locale_path):
245 + continue
246 +
247 + print(f'Validating ADML {locale_path}')
248 + translated, translated_presentations = get_adml_entries(locale_path)
249 +
250 + missing = baseline_ids - set(translated.keys())
251 + extra = set(translated.keys()) - baseline_ids
252 + if missing:
253 + print(f'error: ADML {locale_path} is missing string ids: {sorted(missing)}')
254 + result = False
255 + if extra:
256 + print(f'error: ADML {locale_path} has unexpected string ids: {sorted(extra)}')
257 + result = False
258 +
259 + missing_p = baseline_presentations - translated_presentations
260 + extra_p = translated_presentations - baseline_presentations
261 + if missing_p:
262 + print(f'error: ADML {locale_path} is missing presentation ids: {sorted(missing_p)}')
263 + result = False
264 + if extra_p:
265 + print(f'error: ADML {locale_path} has unexpected presentation ids: {sorted(extra_p)}')
266 + result = False
267 +
268 + for sid in baseline_ids & set(translated.keys()):
269 + _, tokens = baseline[sid]
270 + tvalue, _ = translated[sid]
271 + for tok in tokens:
272 + if tok not in tvalue:
273 + print(f'error: locked token "{tok}" not found in {locale_path} string {sid}: {tvalue}')
274 + result = False
275 +
276 + return result
277 +
278 +def run(resource_folder: str, baseline_language: str, fix: bool, adml_folder: str):
279 baseline_file = f'{resource_folder}/{baseline_language}/Resources.resw'
280
281 strings = get_strings_from_file(baseline_file, True)
@@ -186,6 +287,8 @@ def run(resource_folder: str, baseline_language: str, fix: bool):
287 print(f'Validating inserts in {path}')
288 result &= validate_resource(baseline, path)
289
290 + result &= validate_adml(adml_folder, baseline_language)
291 +
292 if fix and comments:
293 fix_comments(comments, baseline_file, strings)
294
@@ -193,16 +296,17 @@ def run(resource_folder: str, baseline_language: str, fix: bool):
296
297
298 if __name__ == '__main__':
196 - if len(sys.argv) == 3: # Hack to work around pip install errors in the build pipeline
197 - run(sys.argv[1], sys.argv[2], False)
299 + if len(sys.argv) == 1: # Avoid pulling in click for the default (CI) invocation
300 + run(RESOURCE_FOLDER, BASELINE_LANGUAGE, False, ADML_FOLDER)
301 else:
302 import click
303
304 @click.command()
202 - @click.argument('resource-folder', default='localization/strings')
203 - @click.argument('baseline-language', default='en-us')
305 + @click.option('--resource-folder', default=RESOURCE_FOLDER, show_default=True)
306 + @click.option('--baseline-language', default=BASELINE_LANGUAGE, show_default=True)
307 + @click.option('--adml-folder', default=ADML_FOLDER, show_default=True)
308 @click.option('--fix', is_flag=True)
205 - def main(resource_folder: str, baseline_language: str, fix: bool):
206 - run(resource_folder, baseline_language, fix)
207 -
309 + def main(resource_folder: str, baseline_language: str, adml_folder: str, fix: bool):
310 + run(resource_folder, baseline_language, fix, adml_folder)
311 +
312 main()
\ No newline at end of file