##// END OF EJS Templates
packaging: move version derivation to run_wix_packaging()...
Gregory Szorc -
r45273:a5740490 stable
parent child Browse files
Show More
@@ -1,476 +1,475 b''
1 1 # wix.py - WiX installer functionality
2 2 #
3 3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 # no-check-code because Python 3 native.
9 9
10 10 import collections
11 11 import os
12 12 import pathlib
13 13 import re
14 14 import shutil
15 15 import subprocess
16 16 import typing
17 17 import uuid
18 18 import xml.dom.minidom
19 19
20 20 from .downloads import download_entry
21 21 from .py2exe import (
22 22 build_py2exe,
23 23 stage_install,
24 24 )
25 25 from .util import (
26 26 extract_zip_to_directory,
27 27 normalize_windows_version,
28 28 process_install_rules,
29 29 sign_with_signtool,
30 30 )
31 31
32 32
33 33 EXTRA_PACKAGES = {
34 34 'dulwich',
35 35 'distutils',
36 36 'keyring',
37 37 'pygments',
38 38 'win32ctypes',
39 39 }
40 40
41 41
42 42 EXTRA_INSTALL_RULES = [
43 43 ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'),
44 44 ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'),
45 45 ]
46 46
47 47 STAGING_REMOVE_FILES = [
48 48 # We use the RTF variant.
49 49 'copying.txt',
50 50 ]
51 51
52 52 SHORTCUTS = {
53 53 # hg.1.html'
54 54 'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': {
55 55 'Name': 'Mercurial Command Reference',
56 56 },
57 57 # hgignore.5.html
58 58 'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': {
59 59 'Name': 'Mercurial Ignore Files',
60 60 },
61 61 # hgrc.5.html
62 62 'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': {
63 63 'Name': 'Mercurial Configuration Files',
64 64 },
65 65 }
66 66
67 67
68 68 def find_version(source_dir: pathlib.Path):
69 69 version_py = source_dir / 'mercurial' / '__version__.py'
70 70
71 71 with version_py.open('r', encoding='utf-8') as fh:
72 72 source = fh.read().strip()
73 73
74 74 m = re.search('version = b"(.*)"', source)
75 75 return m.group(1)
76 76
77 77
78 78 def ensure_vc90_merge_modules(build_dir):
79 79 x86 = (
80 80 download_entry(
81 81 'vc9-crt-x86-msm',
82 82 build_dir,
83 83 local_name='microsoft.vcxx.crt.x86_msm.msm',
84 84 )[0],
85 85 download_entry(
86 86 'vc9-crt-x86-msm-policy',
87 87 build_dir,
88 88 local_name='policy.x.xx.microsoft.vcxx.crt.x86_msm.msm',
89 89 )[0],
90 90 )
91 91
92 92 x64 = (
93 93 download_entry(
94 94 'vc9-crt-x64-msm',
95 95 build_dir,
96 96 local_name='microsoft.vcxx.crt.x64_msm.msm',
97 97 )[0],
98 98 download_entry(
99 99 'vc9-crt-x64-msm-policy',
100 100 build_dir,
101 101 local_name='policy.x.xx.microsoft.vcxx.crt.x64_msm.msm',
102 102 )[0],
103 103 )
104 104 return {
105 105 'x86': x86,
106 106 'x64': x64,
107 107 }
108 108
109 109
110 110 def run_candle(wix, cwd, wxs, source_dir, defines=None):
111 111 args = [
112 112 str(wix / 'candle.exe'),
113 113 '-nologo',
114 114 str(wxs),
115 115 '-dSourceDir=%s' % source_dir,
116 116 ]
117 117
118 118 if defines:
119 119 args.extend('-d%s=%s' % define for define in sorted(defines.items()))
120 120
121 121 subprocess.run(args, cwd=str(cwd), check=True)
122 122
123 123
124 124 def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str:
125 125 """Create XML string listing every file to be installed."""
126 126
127 127 # We derive GUIDs from a deterministic file path identifier.
128 128 # We shoehorn the name into something that looks like a URL because
129 129 # the UUID namespaces are supposed to work that way (even though
130 130 # the input data probably is never validated).
131 131
132 132 doc = xml.dom.minidom.parseString(
133 133 '<?xml version="1.0" encoding="utf-8"?>'
134 134 '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">'
135 135 '</Wix>'
136 136 )
137 137
138 138 # Assemble the install layout by directory. This makes it easier to
139 139 # emit XML, since each directory has separate entities.
140 140 manifest = collections.defaultdict(dict)
141 141
142 142 for root, dirs, files in os.walk(staging_dir):
143 143 dirs.sort()
144 144
145 145 root = pathlib.Path(root)
146 146 rel_dir = root.relative_to(staging_dir)
147 147
148 148 for i in range(len(rel_dir.parts)):
149 149 parent = '/'.join(rel_dir.parts[0 : i + 1])
150 150 manifest.setdefault(parent, {})
151 151
152 152 for f in sorted(files):
153 153 full = root / f
154 154 manifest[str(rel_dir).replace('\\', '/')][full.name] = full
155 155
156 156 component_groups = collections.defaultdict(list)
157 157
158 158 # Now emit a <Fragment> for each directory.
159 159 # Each directory is composed of a <DirectoryRef> pointing to its parent
160 160 # and defines child <Directory>'s and a <Component> with all the files.
161 161 for dir_name, entries in sorted(manifest.items()):
162 162 # The directory id is derived from the path. But the root directory
163 163 # is special.
164 164 if dir_name == '.':
165 165 parent_directory_id = 'INSTALLDIR'
166 166 else:
167 167 parent_directory_id = 'hg.dir.%s' % dir_name.replace('/', '.')
168 168
169 169 fragment = doc.createElement('Fragment')
170 170 directory_ref = doc.createElement('DirectoryRef')
171 171 directory_ref.setAttribute('Id', parent_directory_id)
172 172
173 173 # Add <Directory> entries for immediate children directories.
174 174 for possible_child in sorted(manifest.keys()):
175 175 if (
176 176 dir_name == '.'
177 177 and '/' not in possible_child
178 178 and possible_child != '.'
179 179 ):
180 180 child_directory_id = 'hg.dir.%s' % possible_child
181 181 name = possible_child
182 182 else:
183 183 if not possible_child.startswith('%s/' % dir_name):
184 184 continue
185 185 name = possible_child[len(dir_name) + 1 :]
186 186 if '/' in name:
187 187 continue
188 188
189 189 child_directory_id = 'hg.dir.%s' % possible_child.replace(
190 190 '/', '.'
191 191 )
192 192
193 193 directory = doc.createElement('Directory')
194 194 directory.setAttribute('Id', child_directory_id)
195 195 directory.setAttribute('Name', name)
196 196 directory_ref.appendChild(directory)
197 197
198 198 # Add <Component>s for files in this directory.
199 199 for rel, source_path in sorted(entries.items()):
200 200 if dir_name == '.':
201 201 full_rel = rel
202 202 else:
203 203 full_rel = '%s/%s' % (dir_name, rel)
204 204
205 205 component_unique_id = (
206 206 'https://www.mercurial-scm.org/wix-installer/0/component/%s'
207 207 % full_rel
208 208 )
209 209 component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id)
210 210 component_id = 'hg.component.%s' % str(component_guid).replace(
211 211 '-', '_'
212 212 )
213 213
214 214 component = doc.createElement('Component')
215 215
216 216 component.setAttribute('Id', component_id)
217 217 component.setAttribute('Guid', str(component_guid).upper())
218 218 component.setAttribute('Win64', 'yes' if is_x64 else 'no')
219 219
220 220 # Assign this component to a top-level group.
221 221 if dir_name == '.':
222 222 component_groups['ROOT'].append(component_id)
223 223 elif '/' in dir_name:
224 224 component_groups[dir_name[0 : dir_name.index('/')]].append(
225 225 component_id
226 226 )
227 227 else:
228 228 component_groups[dir_name].append(component_id)
229 229
230 230 unique_id = (
231 231 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel
232 232 )
233 233 file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id)
234 234
235 235 # IDs have length limits. So use GUID to derive them.
236 236 file_guid_normalized = str(file_guid).replace('-', '_')
237 237 file_id = 'hg.file.%s' % file_guid_normalized
238 238
239 239 file_element = doc.createElement('File')
240 240 file_element.setAttribute('Id', file_id)
241 241 file_element.setAttribute('Source', str(source_path))
242 242 file_element.setAttribute('KeyPath', 'yes')
243 243 file_element.setAttribute('ReadOnly', 'yes')
244 244
245 245 component.appendChild(file_element)
246 246 directory_ref.appendChild(component)
247 247
248 248 fragment.appendChild(directory_ref)
249 249 doc.documentElement.appendChild(fragment)
250 250
251 251 for group, component_ids in sorted(component_groups.items()):
252 252 fragment = doc.createElement('Fragment')
253 253 component_group = doc.createElement('ComponentGroup')
254 254 component_group.setAttribute('Id', 'hg.group.%s' % group)
255 255
256 256 for component_id in component_ids:
257 257 component_ref = doc.createElement('ComponentRef')
258 258 component_ref.setAttribute('Id', component_id)
259 259 component_group.appendChild(component_ref)
260 260
261 261 fragment.appendChild(component_group)
262 262 doc.documentElement.appendChild(fragment)
263 263
264 264 # Add <Shortcut> to files that have it defined.
265 265 for file_id, metadata in sorted(SHORTCUTS.items()):
266 266 els = doc.getElementsByTagName('File')
267 267 els = [el for el in els if el.getAttribute('Id') == file_id]
268 268
269 269 if not els:
270 270 raise Exception('could not find File[Id=%s]' % file_id)
271 271
272 272 for el in els:
273 273 shortcut = doc.createElement('Shortcut')
274 274 shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id)
275 275 shortcut.setAttribute('Directory', 'ProgramMenuDir')
276 276 shortcut.setAttribute('Icon', 'hgIcon.ico')
277 277 shortcut.setAttribute('IconIndex', '0')
278 278 shortcut.setAttribute('Advertise', 'yes')
279 279 for k, v in sorted(metadata.items()):
280 280 shortcut.setAttribute(k, v)
281 281
282 282 el.appendChild(shortcut)
283 283
284 284 return doc.toprettyxml()
285 285
286 286
287 287 def build_installer(
288 288 source_dir: pathlib.Path,
289 289 python_exe: pathlib.Path,
290 290 msi_name='mercurial',
291 291 version=None,
292 292 extra_packages_script=None,
293 293 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
294 294 extra_features: typing.Optional[typing.List[str]] = None,
295 295 signing_info: typing.Optional[typing.Dict[str, str]] = None,
296 296 ):
297 297 """Build a WiX MSI installer.
298 298
299 299 ``source_dir`` is the path to the Mercurial source tree to use.
300 300 ``arch`` is the target architecture. either ``x86`` or ``x64``.
301 301 ``python_exe`` is the path to the Python executable to use/bundle.
302 302 ``version`` is the Mercurial version string. If not defined,
303 303 ``mercurial/__version__.py`` will be consulted.
304 304 ``extra_packages_script`` is a command to be run to inject extra packages
305 305 into the py2exe binary. It should stage packages into the virtualenv and
306 306 print a null byte followed by a newline-separated list of packages that
307 307 should be included in the exe.
308 308 ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}.
309 309 ``extra_features`` is a list of additional named Features to include in
310 310 the build. These must match Feature names in one of the wxs scripts.
311 311 """
312 312 arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86'
313 313
314 314 hg_build_dir = source_dir / 'build'
315 315
316 316 requirements_txt = (
317 317 source_dir / 'contrib' / 'packaging' / 'requirements_win32.txt'
318 318 )
319 319
320 320 build_py2exe(
321 321 source_dir,
322 322 hg_build_dir,
323 323 python_exe,
324 324 'wix',
325 325 requirements_txt,
326 326 extra_packages=EXTRA_PACKAGES,
327 327 extra_packages_script=extra_packages_script,
328 328 )
329 329
330 orig_version = version or find_version(source_dir)
331 version = normalize_windows_version(orig_version)
332 print('using version string: %s' % version)
333 if version != orig_version:
334 print('(normalized from: %s)' % orig_version)
335
336 330 build_dir = hg_build_dir / ('wix-%s' % arch)
337 331 staging_dir = build_dir / 'stage'
338 332
339 333 build_dir.mkdir(exist_ok=True)
340 334
341 335 # Purge the staging directory for every build so packaging is pristine.
342 336 if staging_dir.exists():
343 337 print('purging %s' % staging_dir)
344 338 shutil.rmtree(staging_dir)
345 339
346 340 stage_install(source_dir, staging_dir, lower_case=True)
347 341
348 342 # We also install some extra files.
349 343 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
350 344
351 345 # And remove some files we don't want.
352 346 for f in STAGING_REMOVE_FILES:
353 347 p = staging_dir / f
354 348 if p.exists():
355 349 print('removing %s' % p)
356 350 p.unlink()
357 351
358 352 return run_wix_packaging(
359 353 source_dir,
360 354 build_dir,
361 355 staging_dir,
362 356 arch,
363 357 version=version,
364 orig_version=orig_version,
365 358 msi_name=msi_name,
366 359 extra_wxs=extra_wxs,
367 360 extra_features=extra_features,
368 361 signing_info=signing_info,
369 362 )
370 363
371 364
372 365 def run_wix_packaging(
373 366 source_dir: pathlib.Path,
374 367 build_dir: pathlib.Path,
375 368 staging_dir: pathlib.Path,
376 369 arch: str,
377 370 version: str,
378 orig_version: str,
379 371 msi_name: typing.Optional[str] = "mercurial",
380 372 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
381 373 extra_features: typing.Optional[typing.List[str]] = None,
382 374 signing_info: typing.Optional[typing.Dict[str, str]] = None,
383 375 ):
384 376 """Invokes WiX to package up a built Mercurial.
385 377
386 378 ``signing_info`` is a dict defining properties to facilitate signing the
387 379 installer. Recognized keys include ``name``, ``subject_name``,
388 380 ``cert_path``, ``cert_password``, and ``timestamp_url``. If populated,
389 381 we will sign both the hg.exe and the .msi using the signing credentials
390 382 specified.
391 383 """
384
385 orig_version = version or find_version(source_dir)
386 version = normalize_windows_version(orig_version)
387 print('using version string: %s' % version)
388 if version != orig_version:
389 print('(normalized from: %s)' % orig_version)
390
392 391 if signing_info:
393 392 sign_with_signtool(
394 393 staging_dir / "hg.exe",
395 394 "%s %s" % (signing_info["name"], version),
396 395 subject_name=signing_info["subject_name"],
397 396 cert_path=signing_info["cert_path"],
398 397 cert_password=signing_info["cert_password"],
399 398 timestamp_url=signing_info["timestamp_url"],
400 399 )
401 400
402 401 wix_dir = source_dir / 'contrib' / 'packaging' / 'wix'
403 402
404 403 wix_pkg, wix_entry = download_entry('wix', build_dir)
405 404 wix_path = build_dir / ('wix-%s' % wix_entry['version'])
406 405
407 406 if not wix_path.exists():
408 407 extract_zip_to_directory(wix_pkg, wix_path)
409 408
410 409 ensure_vc90_merge_modules(build_dir)
411 410
412 411 source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
413 412
414 413 defines = {'Platform': arch}
415 414
416 415 # Derive a .wxs file with the staged files.
417 416 manifest_wxs = build_dir / 'stage.wxs'
418 417 with manifest_wxs.open('w', encoding='utf-8') as fh:
419 418 fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
420 419
421 420 run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
422 421
423 422 for source, rel_path in sorted((extra_wxs or {}).items()):
424 423 run_candle(wix_path, build_dir, source, rel_path, defines=defines)
425 424
426 425 source = wix_dir / 'mercurial.wxs'
427 426 defines['Version'] = version
428 427 defines['Comments'] = 'Installs Mercurial version %s' % version
429 428 defines['VCRedistSrcDir'] = str(build_dir)
430 429 if extra_features:
431 430 assert all(';' not in f for f in extra_features)
432 431 defines['MercurialExtraFeatures'] = ';'.join(extra_features)
433 432
434 433 run_candle(wix_path, build_dir, source, source_build_rel, defines=defines)
435 434
436 435 msi_path = (
437 436 source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, orig_version, arch))
438 437 )
439 438
440 439 args = [
441 440 str(wix_path / 'light.exe'),
442 441 '-nologo',
443 442 '-ext',
444 443 'WixUIExtension',
445 444 '-sw1076',
446 445 '-spdb',
447 446 '-o',
448 447 str(msi_path),
449 448 ]
450 449
451 450 for source, rel_path in sorted((extra_wxs or {}).items()):
452 451 assert source.endswith('.wxs')
453 452 source = os.path.basename(source)
454 453 args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
455 454
456 455 args.extend(
457 456 [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),]
458 457 )
459 458
460 459 subprocess.run(args, cwd=str(source_dir), check=True)
461 460
462 461 print('%s created' % msi_path)
463 462
464 463 if signing_info:
465 464 sign_with_signtool(
466 465 msi_path,
467 466 "%s %s" % (signing_info["name"], version),
468 467 subject_name=signing_info["subject_name"],
469 468 cert_path=signing_info["cert_path"],
470 469 cert_password=signing_info["cert_password"],
471 470 timestamp_url=signing_info["timestamp_url"],
472 471 )
473 472
474 473 return {
475 474 'msi_path': msi_path,
476 475 }
General Comments 0
You need to be logged in to leave comments. Login now