Phase 9: Add native processors and reproducible Linux release tooling
This commit is contained in:
parent
7db5fb7738
commit
de403b37d7
43 changed files with 1656 additions and 19 deletions
81
tools/dist.py
Normal file
81
tools/dist.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic source archive using indexed files, or an exported manifest."""
|
||||
import argparse
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
|
||||
def version(root):
|
||||
text = (root / 'include/xtc/xtc_version.h').read_text()
|
||||
match = re.search(r'^#define XTC_VERSION_STRING "([0-9]+\.[0-9]+\.[0-9]+)"$', text, re.M)
|
||||
if not match:
|
||||
raise ValueError('Invalid project version')
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def manifest(root):
|
||||
if (root / '.git').exists():
|
||||
data = subprocess.check_output(['git', 'ls-files', '--stage', '-z'], cwd=root)
|
||||
entries = []
|
||||
for record in data.split(b'\0'):
|
||||
if not record:
|
||||
continue
|
||||
metadata, name = record.split(b'\t', 1)
|
||||
mode, _, stage = metadata.split()
|
||||
if stage != b'0' or mode not in (b'100644', b'100755'):
|
||||
raise ValueError('Only resolved regular indexed files can be packaged')
|
||||
entries.append([name.decode('utf-8'), mode == b'100755'])
|
||||
else:
|
||||
entries = json.loads((root / 'SOURCE_MANIFEST.json').read_text())
|
||||
seen = set()
|
||||
for name, executable in entries:
|
||||
path = PurePosixPath(name)
|
||||
if (not name or path.is_absolute() or '..' in path.parts or str(path) != name
|
||||
or name in seen or name == 'SOURCE_MANIFEST.json' or not isinstance(executable, bool)):
|
||||
raise ValueError('Invalid source manifest entry')
|
||||
seen.add(name)
|
||||
if (root / name).is_symlink() or not (root / name).is_file():
|
||||
raise ValueError(f'Not a regular source file: {name}')
|
||||
return sorted(entries)
|
||||
|
||||
|
||||
def archive(root, output):
|
||||
entries = manifest(root)
|
||||
epoch = int(os.environ.get('SOURCE_DATE_EPOCH', '0'))
|
||||
if epoch < 0 or epoch > 4294967295:
|
||||
raise ValueError('SOURCE_DATE_EPOCH must fit an unsigned 32-bit timestamp')
|
||||
top = 'xtc-' + version(root)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.xtc-dist-', dir=output.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as raw, gzip.GzipFile(filename='', mode='wb', fileobj=raw,
|
||||
mtime=epoch, compresslevel=9) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as tar:
|
||||
contents = [(name, (root / name).read_bytes(), executable) for name, executable in entries]
|
||||
contents.append(('SOURCE_MANIFEST.json', (json.dumps(entries, indent=2) + '\n').encode(), False))
|
||||
for name, data, executable in sorted(contents):
|
||||
info = tarfile.TarInfo(top + '/' + name)
|
||||
info.size = len(data)
|
||||
info.mode = 0o755 if executable else 0o644
|
||||
info.mtime = epoch
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
os.replace(temporary, output)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, default=Path.cwd())
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
archive(args.root.resolve(), args.output.resolve())
|
||||
16
tools/pkgconfig.py
Normal file
16
tools/pkgconfig.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate pkg-config metadata without shell/sed interpolation of paths."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from dist import version
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--prefix', required=True)
|
||||
parser.add_argument('--jack', default='')
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if not args.prefix.startswith('/') or any(c in args.prefix for c in '\n\r\0"$\\'):
|
||||
parser.error('PREFIX must be absolute, without newlines, quotes, $, or backslashes')
|
||||
text = Path('packaging/xtc.pc.in').read_text().replace('@PREFIX@', args.prefix)
|
||||
text = text.replace('@VERSION@', version(Path.cwd())).replace('@JACK@', args.jack)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(text)
|
||||
44
tools/uninstall.py
Normal file
44
tools/uninstall.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Remove only this source version's installed files; never recurse directories."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
|
||||
def owned_files(source):
|
||||
files = ['lib/libxtc.a', 'lib/pkgconfig/xtc.pc', 'bin/xtc-check', 'bin/xtc',
|
||||
'share/applications/xtc.desktop', 'share/licenses/xtc/LICENSE',
|
||||
'share/doc/xtc/README.md']
|
||||
files += ['include/xtc/' + p.name for p in sorted((source / 'include/xtc').glob('*.h'))]
|
||||
files += ['share/doc/xtc/' + p.name for p in sorted((source / 'docs').glob('*.md'))]
|
||||
files += ['share/doc/xtc/decisions/' + p.name for p in sorted((source / 'docs/decisions').glob('*.md'))]
|
||||
return files
|
||||
|
||||
|
||||
def uninstall(root, source):
|
||||
if not root.is_absolute() or root == Path('/'):
|
||||
raise ValueError('Installation root must be absolute and not /')
|
||||
files = [root / name for name in owned_files(source)]
|
||||
# Preflight before changing anything; never unlink a directory at a file path.
|
||||
for path in files:
|
||||
try:
|
||||
mode = path.lstat().st_mode
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if not (stat.S_ISREG(mode) or stat.S_ISLNK(mode)):
|
||||
raise ValueError(f'Refusing non-file: {path}')
|
||||
for path in files:
|
||||
path.unlink(missing_ok=True)
|
||||
parents = {parent for path in files for parent in path.parents if parent == root or root in parent.parents}
|
||||
for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True):
|
||||
try:
|
||||
parent.rmdir()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass # Shared/nonempty directories remain untouched.
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
uninstall(args.root, Path(__file__).resolve().parent.parent)
|
||||
Loading…
Add table
Add a link
Reference in a new issue