##// END OF EJS Templates
wix: more robust normalization of RC version components...
Gregory Szorc -
r44632:8d653abe stable
parent child Browse files
Show More
@@ -1,518 +1,550 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 process_install_rules,
28 28 sign_with_signtool,
29 29 )
30 30
31 31
32 32 EXTRA_PACKAGES = {
33 33 'distutils',
34 34 'pygments',
35 35 }
36 36
37 37
38 38 EXTRA_INSTALL_RULES = [
39 39 ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'),
40 40 ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'),
41 41 ]
42 42
43 43 STAGING_REMOVE_FILES = [
44 44 # We use the RTF variant.
45 45 'copying.txt',
46 46 ]
47 47
48 48 SHORTCUTS = {
49 49 # hg.1.html'
50 50 'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': {
51 51 'Name': 'Mercurial Command Reference',
52 52 },
53 53 # hgignore.5.html
54 54 'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': {
55 55 'Name': 'Mercurial Ignore Files',
56 56 },
57 57 # hgrc.5.html
58 58 'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': {
59 59 'Name': 'Mercurial Configuration Files',
60 60 },
61 61 }
62 62
63 63
64 64 def find_version(source_dir: pathlib.Path):
65 65 version_py = source_dir / 'mercurial' / '__version__.py'
66 66
67 67 with version_py.open('r', encoding='utf-8') as fh:
68 68 source = fh.read().strip()
69 69
70 70 m = re.search('version = b"(.*)"', source)
71 71 return m.group(1)
72 72
73 73
74 74 def normalize_version(version):
75 75 """Normalize Mercurial version string so WiX accepts it.
76 76
77 Version strings have to be numeric X.Y.Z.
77 Version strings have to be numeric ``A.B.C[.D]`` to conform with MSI's
78 requirements.
79
80 We normalize RC version or the commit count to a 4th version component.
81 We store this in the 4th component because ``A.B.C`` releases do occur
82 and we want an e.g. ``5.3rc0`` version to be semantically less than a
83 ``5.3.1rc2`` version. This requires always reserving the 3rd version
84 component for the point release and the ``X.YrcN`` release is always
85 point release 0.
86
87 In the case of an RC and presence of ``+`` suffix data, we can't use both
88 because the version format is limited to 4 components. We choose to use
89 RC and throw away the commit count in the suffix. This means we could
90 produce multiple installers with the same normalized version string.
91
92 >>> normalize_version("5.3")
93 '5.3.0'
94
95 >>> normalize_version("5.3rc0")
96 '5.3.0.0'
97
98 >>> normalize_version("5.3rc1")
99 '5.3.0.1'
100
101 >>> normalize_version("5.3rc1+2-abcdef")
102 '5.3.0.1'
103
104 >>> normalize_version("5.3+2-abcdef")
105 '5.3.0.2'
78 106 """
79
80 107 if '+' in version:
81 108 version, extra = version.split('+', 1)
82 109 else:
83 110 extra = None
84 111
85 112 # 4.9rc0
86 113 if version[:-1].endswith('rc'):
114 rc = int(version[-1:])
87 115 version = version[:-3]
116 else:
117 rc = None
88 118
119 # Ensure we have at least X.Y version components.
89 120 versions = [int(v) for v in version.split('.')]
90 121 while len(versions) < 3:
91 122 versions.append(0)
92 123
93 major, minor, build = versions[:3]
124 if len(versions) < 4:
125 if rc is not None:
126 versions.append(rc)
127 elif extra:
128 # <commit count>-<hash>+<date>
129 versions.append(int(extra.split('-')[0]))
94 130
95 if extra:
96 # <commit count>-<hash>+<date>
97 build = int(extra.split('-')[0])
98
99 return '.'.join('%d' % x for x in (major, minor, build))
131 return '.'.join('%d' % x for x in versions[0:4])
100 132
101 133
102 134 def ensure_vc90_merge_modules(build_dir):
103 135 x86 = (
104 136 download_entry(
105 137 'vc9-crt-x86-msm',
106 138 build_dir,
107 139 local_name='microsoft.vcxx.crt.x86_msm.msm',
108 140 )[0],
109 141 download_entry(
110 142 'vc9-crt-x86-msm-policy',
111 143 build_dir,
112 144 local_name='policy.x.xx.microsoft.vcxx.crt.x86_msm.msm',
113 145 )[0],
114 146 )
115 147
116 148 x64 = (
117 149 download_entry(
118 150 'vc9-crt-x64-msm',
119 151 build_dir,
120 152 local_name='microsoft.vcxx.crt.x64_msm.msm',
121 153 )[0],
122 154 download_entry(
123 155 'vc9-crt-x64-msm-policy',
124 156 build_dir,
125 157 local_name='policy.x.xx.microsoft.vcxx.crt.x64_msm.msm',
126 158 )[0],
127 159 )
128 160 return {
129 161 'x86': x86,
130 162 'x64': x64,
131 163 }
132 164
133 165
134 166 def run_candle(wix, cwd, wxs, source_dir, defines=None):
135 167 args = [
136 168 str(wix / 'candle.exe'),
137 169 '-nologo',
138 170 str(wxs),
139 171 '-dSourceDir=%s' % source_dir,
140 172 ]
141 173
142 174 if defines:
143 175 args.extend('-d%s=%s' % define for define in sorted(defines.items()))
144 176
145 177 subprocess.run(args, cwd=str(cwd), check=True)
146 178
147 179
148 180 def make_post_build_signing_fn(
149 181 name,
150 182 subject_name=None,
151 183 cert_path=None,
152 184 cert_password=None,
153 185 timestamp_url=None,
154 186 ):
155 187 """Create a callable that will use signtool to sign hg.exe."""
156 188
157 189 def post_build_sign(source_dir, build_dir, dist_dir, version):
158 190 description = '%s %s' % (name, version)
159 191
160 192 sign_with_signtool(
161 193 dist_dir / 'hg.exe',
162 194 description,
163 195 subject_name=subject_name,
164 196 cert_path=cert_path,
165 197 cert_password=cert_password,
166 198 timestamp_url=timestamp_url,
167 199 )
168 200
169 201 return post_build_sign
170 202
171 203
172 204 def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str:
173 205 """Create XML string listing every file to be installed."""
174 206
175 207 # We derive GUIDs from a deterministic file path identifier.
176 208 # We shoehorn the name into something that looks like a URL because
177 209 # the UUID namespaces are supposed to work that way (even though
178 210 # the input data probably is never validated).
179 211
180 212 doc = xml.dom.minidom.parseString(
181 213 '<?xml version="1.0" encoding="utf-8"?>'
182 214 '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">'
183 215 '</Wix>'
184 216 )
185 217
186 218 # Assemble the install layout by directory. This makes it easier to
187 219 # emit XML, since each directory has separate entities.
188 220 manifest = collections.defaultdict(dict)
189 221
190 222 for root, dirs, files in os.walk(staging_dir):
191 223 dirs.sort()
192 224
193 225 root = pathlib.Path(root)
194 226 rel_dir = root.relative_to(staging_dir)
195 227
196 228 for i in range(len(rel_dir.parts)):
197 229 parent = '/'.join(rel_dir.parts[0 : i + 1])
198 230 manifest.setdefault(parent, {})
199 231
200 232 for f in sorted(files):
201 233 full = root / f
202 234 manifest[str(rel_dir).replace('\\', '/')][full.name] = full
203 235
204 236 component_groups = collections.defaultdict(list)
205 237
206 238 # Now emit a <Fragment> for each directory.
207 239 # Each directory is composed of a <DirectoryRef> pointing to its parent
208 240 # and defines child <Directory>'s and a <Component> with all the files.
209 241 for dir_name, entries in sorted(manifest.items()):
210 242 # The directory id is derived from the path. But the root directory
211 243 # is special.
212 244 if dir_name == '.':
213 245 parent_directory_id = 'INSTALLDIR'
214 246 else:
215 247 parent_directory_id = 'hg.dir.%s' % dir_name.replace('/', '.')
216 248
217 249 fragment = doc.createElement('Fragment')
218 250 directory_ref = doc.createElement('DirectoryRef')
219 251 directory_ref.setAttribute('Id', parent_directory_id)
220 252
221 253 # Add <Directory> entries for immediate children directories.
222 254 for possible_child in sorted(manifest.keys()):
223 255 if (
224 256 dir_name == '.'
225 257 and '/' not in possible_child
226 258 and possible_child != '.'
227 259 ):
228 260 child_directory_id = 'hg.dir.%s' % possible_child
229 261 name = possible_child
230 262 else:
231 263 if not possible_child.startswith('%s/' % dir_name):
232 264 continue
233 265 name = possible_child[len(dir_name) + 1 :]
234 266 if '/' in name:
235 267 continue
236 268
237 269 child_directory_id = 'hg.dir.%s' % possible_child.replace(
238 270 '/', '.'
239 271 )
240 272
241 273 directory = doc.createElement('Directory')
242 274 directory.setAttribute('Id', child_directory_id)
243 275 directory.setAttribute('Name', name)
244 276 directory_ref.appendChild(directory)
245 277
246 278 # Add <Component>s for files in this directory.
247 279 for rel, source_path in sorted(entries.items()):
248 280 if dir_name == '.':
249 281 full_rel = rel
250 282 else:
251 283 full_rel = '%s/%s' % (dir_name, rel)
252 284
253 285 component_unique_id = (
254 286 'https://www.mercurial-scm.org/wix-installer/0/component/%s'
255 287 % full_rel
256 288 )
257 289 component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id)
258 290 component_id = 'hg.component.%s' % str(component_guid).replace(
259 291 '-', '_'
260 292 )
261 293
262 294 component = doc.createElement('Component')
263 295
264 296 component.setAttribute('Id', component_id)
265 297 component.setAttribute('Guid', str(component_guid).upper())
266 298 component.setAttribute('Win64', 'yes' if is_x64 else 'no')
267 299
268 300 # Assign this component to a top-level group.
269 301 if dir_name == '.':
270 302 component_groups['ROOT'].append(component_id)
271 303 elif '/' in dir_name:
272 304 component_groups[dir_name[0 : dir_name.index('/')]].append(
273 305 component_id
274 306 )
275 307 else:
276 308 component_groups[dir_name].append(component_id)
277 309
278 310 unique_id = (
279 311 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel
280 312 )
281 313 file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id)
282 314
283 315 # IDs have length limits. So use GUID to derive them.
284 316 file_guid_normalized = str(file_guid).replace('-', '_')
285 317 file_id = 'hg.file.%s' % file_guid_normalized
286 318
287 319 file_element = doc.createElement('File')
288 320 file_element.setAttribute('Id', file_id)
289 321 file_element.setAttribute('Source', str(source_path))
290 322 file_element.setAttribute('KeyPath', 'yes')
291 323 file_element.setAttribute('ReadOnly', 'yes')
292 324
293 325 component.appendChild(file_element)
294 326 directory_ref.appendChild(component)
295 327
296 328 fragment.appendChild(directory_ref)
297 329 doc.documentElement.appendChild(fragment)
298 330
299 331 for group, component_ids in sorted(component_groups.items()):
300 332 fragment = doc.createElement('Fragment')
301 333 component_group = doc.createElement('ComponentGroup')
302 334 component_group.setAttribute('Id', 'hg.group.%s' % group)
303 335
304 336 for component_id in component_ids:
305 337 component_ref = doc.createElement('ComponentRef')
306 338 component_ref.setAttribute('Id', component_id)
307 339 component_group.appendChild(component_ref)
308 340
309 341 fragment.appendChild(component_group)
310 342 doc.documentElement.appendChild(fragment)
311 343
312 344 # Add <Shortcut> to files that have it defined.
313 345 for file_id, metadata in sorted(SHORTCUTS.items()):
314 346 els = doc.getElementsByTagName('File')
315 347 els = [el for el in els if el.getAttribute('Id') == file_id]
316 348
317 349 if not els:
318 350 raise Exception('could not find File[Id=%s]' % file_id)
319 351
320 352 for el in els:
321 353 shortcut = doc.createElement('Shortcut')
322 354 shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id)
323 355 shortcut.setAttribute('Directory', 'ProgramMenuDir')
324 356 shortcut.setAttribute('Icon', 'hgIcon.ico')
325 357 shortcut.setAttribute('IconIndex', '0')
326 358 shortcut.setAttribute('Advertise', 'yes')
327 359 for k, v in sorted(metadata.items()):
328 360 shortcut.setAttribute(k, v)
329 361
330 362 el.appendChild(shortcut)
331 363
332 364 return doc.toprettyxml()
333 365
334 366
335 367 def build_installer(
336 368 source_dir: pathlib.Path,
337 369 python_exe: pathlib.Path,
338 370 msi_name='mercurial',
339 371 version=None,
340 372 post_build_fn=None,
341 373 extra_packages_script=None,
342 374 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
343 375 extra_features: typing.Optional[typing.List[str]] = None,
344 376 ):
345 377 """Build a WiX MSI installer.
346 378
347 379 ``source_dir`` is the path to the Mercurial source tree to use.
348 380 ``arch`` is the target architecture. either ``x86`` or ``x64``.
349 381 ``python_exe`` is the path to the Python executable to use/bundle.
350 382 ``version`` is the Mercurial version string. If not defined,
351 383 ``mercurial/__version__.py`` will be consulted.
352 384 ``post_build_fn`` is a callable that will be called after building
353 385 Mercurial but before invoking WiX. It can be used to e.g. facilitate
354 386 signing. It is passed the paths to the Mercurial source, build, and
355 387 dist directories and the resolved Mercurial version.
356 388 ``extra_packages_script`` is a command to be run to inject extra packages
357 389 into the py2exe binary. It should stage packages into the virtualenv and
358 390 print a null byte followed by a newline-separated list of packages that
359 391 should be included in the exe.
360 392 ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}.
361 393 ``extra_features`` is a list of additional named Features to include in
362 394 the build. These must match Feature names in one of the wxs scripts.
363 395 """
364 396 arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86'
365 397
366 398 hg_build_dir = source_dir / 'build'
367 399 dist_dir = source_dir / 'dist'
368 400 wix_dir = source_dir / 'contrib' / 'packaging' / 'wix'
369 401
370 402 requirements_txt = wix_dir / 'requirements.txt'
371 403
372 404 build_py2exe(
373 405 source_dir,
374 406 hg_build_dir,
375 407 python_exe,
376 408 'wix',
377 409 requirements_txt,
378 410 extra_packages=EXTRA_PACKAGES,
379 411 extra_packages_script=extra_packages_script,
380 412 )
381 413
382 414 version = version or normalize_version(find_version(source_dir))
383 415 print('using version string: %s' % version)
384 416
385 417 if post_build_fn:
386 418 post_build_fn(source_dir, hg_build_dir, dist_dir, version)
387 419
388 420 build_dir = hg_build_dir / ('wix-%s' % arch)
389 421 staging_dir = build_dir / 'stage'
390 422
391 423 build_dir.mkdir(exist_ok=True)
392 424
393 425 # Purge the staging directory for every build so packaging is pristine.
394 426 if staging_dir.exists():
395 427 print('purging %s' % staging_dir)
396 428 shutil.rmtree(staging_dir)
397 429
398 430 stage_install(source_dir, staging_dir, lower_case=True)
399 431
400 432 # We also install some extra files.
401 433 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
402 434
403 435 # And remove some files we don't want.
404 436 for f in STAGING_REMOVE_FILES:
405 437 p = staging_dir / f
406 438 if p.exists():
407 439 print('removing %s' % p)
408 440 p.unlink()
409 441
410 442 wix_pkg, wix_entry = download_entry('wix', hg_build_dir)
411 443 wix_path = hg_build_dir / ('wix-%s' % wix_entry['version'])
412 444
413 445 if not wix_path.exists():
414 446 extract_zip_to_directory(wix_pkg, wix_path)
415 447
416 448 ensure_vc90_merge_modules(hg_build_dir)
417 449
418 450 source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
419 451
420 452 defines = {'Platform': arch}
421 453
422 454 # Derive a .wxs file with the staged files.
423 455 manifest_wxs = build_dir / 'stage.wxs'
424 456 with manifest_wxs.open('w', encoding='utf-8') as fh:
425 457 fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
426 458
427 459 run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
428 460
429 461 for source, rel_path in sorted((extra_wxs or {}).items()):
430 462 run_candle(wix_path, build_dir, source, rel_path, defines=defines)
431 463
432 464 source = wix_dir / 'mercurial.wxs'
433 465 defines['Version'] = version
434 466 defines['Comments'] = 'Installs Mercurial version %s' % version
435 467 defines['VCRedistSrcDir'] = str(hg_build_dir)
436 468 if extra_features:
437 469 assert all(';' not in f for f in extra_features)
438 470 defines['MercurialExtraFeatures'] = ';'.join(extra_features)
439 471
440 472 run_candle(wix_path, build_dir, source, source_build_rel, defines=defines)
441 473
442 474 msi_path = (
443 475 source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, version, arch))
444 476 )
445 477
446 478 args = [
447 479 str(wix_path / 'light.exe'),
448 480 '-nologo',
449 481 '-ext',
450 482 'WixUIExtension',
451 483 '-sw1076',
452 484 '-spdb',
453 485 '-o',
454 486 str(msi_path),
455 487 ]
456 488
457 489 for source, rel_path in sorted((extra_wxs or {}).items()):
458 490 assert source.endswith('.wxs')
459 491 source = os.path.basename(source)
460 492 args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
461 493
462 494 args.extend(
463 495 [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),]
464 496 )
465 497
466 498 subprocess.run(args, cwd=str(source_dir), check=True)
467 499
468 500 print('%s created' % msi_path)
469 501
470 502 return {
471 503 'msi_path': msi_path,
472 504 }
473 505
474 506
475 507 def build_signed_installer(
476 508 source_dir: pathlib.Path,
477 509 python_exe: pathlib.Path,
478 510 name: str,
479 511 version=None,
480 512 subject_name=None,
481 513 cert_path=None,
482 514 cert_password=None,
483 515 timestamp_url=None,
484 516 extra_packages_script=None,
485 517 extra_wxs=None,
486 518 extra_features=None,
487 519 ):
488 520 """Build an installer with signed executables."""
489 521
490 522 post_build_fn = make_post_build_signing_fn(
491 523 name,
492 524 subject_name=subject_name,
493 525 cert_path=cert_path,
494 526 cert_password=cert_password,
495 527 timestamp_url=timestamp_url,
496 528 )
497 529
498 530 info = build_installer(
499 531 source_dir,
500 532 python_exe=python_exe,
501 533 msi_name=name.lower(),
502 534 version=version,
503 535 post_build_fn=post_build_fn,
504 536 extra_packages_script=extra_packages_script,
505 537 extra_wxs=extra_wxs,
506 538 extra_features=extra_features,
507 539 )
508 540
509 541 description = '%s %s' % (name, version)
510 542
511 543 sign_with_signtool(
512 544 info['msi_path'],
513 545 description,
514 546 subject_name=subject_name,
515 547 cert_path=cert_path,
516 548 cert_password=cert_password,
517 549 timestamp_url=timestamp_url,
518 550 )
General Comments 0
You need to be logged in to leave comments. Login now