Add Fedora and Arch package recipes (#24)
* Package Ferese for Fedora and Arch releases * Build release packages alongside workspace checks * Include dependency licenses and software GPU test drivers * Limit package checks to release PRs and fix Arch Cargo dependency * Prepare Ferese 0.1.0-beta * Release config locks before closing inherited file descriptions * Install the lock PAM policy for native release checks * Include xauth in distribution package test dependencies * Regenerate Arch Git package metadata * Run package checks with an explicit UTC timezone * Remove unused bundled fonts and accept installed license receipts * Format the system font test helper * Load an installed font for headless text rendering checks * Isolate package test runtimes and reap container child processes
This commit is contained in:
1 parent
587602b493
commit
1b9fabbbe4
25 files changed
+1019
-170
No files matched your search
@@ -16,6 +16,7 @@ PAGES = [
|
||||
('screenshots', 'Screenshots', ROOT / 'docs/screenshots.md'),
|
||||
('why-ferese', 'Why Ferese', ROOT / 'docs/why-ferese.md'),
|
||||
('installation', 'Installation', ROOT / 'docs/installation.md'),
|
||||
('packaging', 'Packages and releases', ROOT / 'docs/packaging.md'),
|
||||
('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
|
||||
('animation-model', 'Animation model', ROOT / 'docs/animation-model.md'),
|
||||
('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'),
|
||||
|
||||
@@ -147,6 +147,8 @@ def validate_bundle(bundle, *, installed=False):
|
||||
# verifiable for upgrades and rollback without adding it to new bundles.
|
||||
if allowed is None and installed and re.fullmatch(r'wallpapers/[A-Za-z0-9._-]+\.jpe?g', target):
|
||||
allowed = {'target': target, 'mode': '0644'}
|
||||
if allowed is None and installed and target in {'licenses/Cantarell-LICENSE.txt', 'licenses/Comfortaa-LICENSE.txt'}:
|
||||
allowed = {'target': target, 'mode': '0644'}
|
||||
if allowed is None and target == 'wallpapers/ferese.svg':
|
||||
allowed = {'target': target, 'mode': '0644'}
|
||||
if allowed is None and re.fullmatch(r'licenses/theme-[A-Za-z0-9._-]+\.txt', target):
|
||||
|
||||
@@ -85,11 +85,6 @@
|
||||
"target": "config.example.kdl",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "assets/fonts/Comfortaa-LICENSE.txt",
|
||||
"target": "licenses/Comfortaa-LICENSE.txt",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "assets/themes/licenses/*.txt",
|
||||
"target": "licenses/theme-{name}",
|
||||
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare offline release sources and stage the complete distro package."""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(*args, cwd=ROOT, env=None):
|
||||
return subprocess.check_output(args, cwd=cwd, env=env, text=True)
|
||||
|
||||
|
||||
def version():
|
||||
return tomllib.loads((ROOT / 'Cargo.toml').read_text())['workspace']['package']['version']
|
||||
|
||||
|
||||
def check_tag(tag, release_version):
|
||||
if not re.fullmatch(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?', release_version):
|
||||
raise ValueError(f'Unsupported release version: {release_version}')
|
||||
|
||||
if tag and tag != f'v{release_version}':
|
||||
raise ValueError(f'Tag {tag} does not match Cargo version {release_version}')
|
||||
|
||||
|
||||
def licenses(destination, metadata):
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
report = []
|
||||
for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])):
|
||||
if package['source'] is None:
|
||||
continue
|
||||
|
||||
directory = Path(package['manifest_path']).parent
|
||||
source_hash = hashlib.sha256(package['source'].encode()).hexdigest()[:8]
|
||||
name = f"{package['name']}-{package['version']}-{source_hash}"
|
||||
files = set()
|
||||
search = [directory]
|
||||
if package['source'].startswith('git+'):
|
||||
for ancestor in directory.parents:
|
||||
search.append(ancestor)
|
||||
if (ancestor / '.git').exists():
|
||||
break
|
||||
|
||||
for parent in search:
|
||||
for pattern in ('LICENSE*', 'LICENCE*', 'COPYING*', 'NOTICE*', 'UNLICENSE*'):
|
||||
files.update(parent.glob(pattern))
|
||||
|
||||
if package.get('license_file'):
|
||||
files.add(directory / package['license_file'])
|
||||
|
||||
for path in sorted(files):
|
||||
if path.is_file():
|
||||
repeated = sum(other.name == path.name for other in files) > 1
|
||||
filename = f'{path.parent.name}-{path.name}' if repeated else path.name
|
||||
target = destination / name / filename
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(path, target)
|
||||
|
||||
report.append({'name': package['name'], 'version': package['version'],
|
||||
'source': package['source'], 'license': package['license'],
|
||||
'notice_directory': name})
|
||||
|
||||
(destination / 'dependencies.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
|
||||
|
||||
def stage(destdir, binary_dir, pam):
|
||||
destdir = Path(destdir).absolute()
|
||||
if destdir == Path('/') or destdir.is_symlink():
|
||||
raise ValueError('DESTDIR must be a private package staging directory')
|
||||
|
||||
if destdir.exists() and any(destdir.iterdir()):
|
||||
raise ValueError('DESTDIR must be empty')
|
||||
|
||||
inventory = json.loads((ROOT / 'scripts/installer/inventory.json').read_text())
|
||||
planned = []
|
||||
links = []
|
||||
for entry in inventory['files']:
|
||||
if entry['target'].startswith('pam/'):
|
||||
continue
|
||||
|
||||
if entry['source'].startswith('target/release/'):
|
||||
sources = [Path(binary_dir).absolute() / Path(entry['source']).name]
|
||||
else:
|
||||
sources = sorted(ROOT.glob(entry['source']))
|
||||
if not sources:
|
||||
raise ValueError(f"Missing package asset: {entry['source']}")
|
||||
|
||||
for source in sources:
|
||||
|
||||
target = entry['target'].replace('{name}', source.name)
|
||||
if 'destination' in entry:
|
||||
target = entry['destination'].replace('/usr/local/', '/usr/').lstrip('/')
|
||||
elif target.startswith(('wallpapers/', 'licenses/')):
|
||||
prefix = 'usr/share/ferese' if target.startswith('wallpapers/') else 'usr/share'
|
||||
if target.startswith('licenses/'):
|
||||
target = 'licenses/ferese/' + target.removeprefix('licenses/')
|
||||
target = f'{prefix}/{target}'
|
||||
else:
|
||||
target = f'usr/libexec/ferese/{target}'
|
||||
|
||||
planned.append((source, target, int(entry['mode'], 8)))
|
||||
if entry.get('link'):
|
||||
links.append((f'usr/bin/{source.name}', f'/usr/libexec/ferese/{source.name}'))
|
||||
|
||||
planned.extend([(ROOT / f'packaging/pam.d/ferese-lock.{pam}', 'etc/pam.d/ferese-lock', 0o644),
|
||||
(ROOT / 'LICENSE', 'usr/share/licenses/ferese/LICENSE', 0o644),
|
||||
(ROOT / 'README.md', 'usr/share/doc/ferese/README.md', 0o644)])
|
||||
# Validate the complete inventory before writing any part of the package.
|
||||
for source, _, _ in planned:
|
||||
if not source.is_file():
|
||||
raise ValueError(f'Missing package file: {source}')
|
||||
for source, target, mode in planned:
|
||||
destination = destdir / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, destination)
|
||||
destination.chmod(mode)
|
||||
if source.suffix in ('.desktop', '.service', '.target', '.portal', '.conf'):
|
||||
text = destination.read_text().replace('/usr/local/lib/ferese/current/', '/usr/libexec/ferese/')
|
||||
destination.write_text(text.replace('/usr/local/', '/usr/'))
|
||||
|
||||
bundled = ROOT / 'bundled-licenses'
|
||||
if bundled.exists():
|
||||
shutil.copytree(bundled, destdir / 'usr/share/licenses/ferese/dependencies', dirs_exist_ok=True)
|
||||
else:
|
||||
metadata = json.loads(run('cargo', 'metadata', '--locked', '--offline', '--format-version', '1'))
|
||||
licenses(destdir / 'usr/share/licenses/ferese/dependencies', metadata)
|
||||
|
||||
for path in (destdir / 'usr/share/licenses/ferese').rglob('*'):
|
||||
if path.is_file():
|
||||
path.chmod(0o644)
|
||||
|
||||
for path in destdir.rglob('*'):
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
path.chmod(0o755)
|
||||
|
||||
links.append(('usr/libexec/ferese/wallpapers', '/usr/share/ferese/wallpapers'))
|
||||
for target, source in links:
|
||||
destination = destdir / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.symlink_to(source)
|
||||
|
||||
|
||||
def source(output, tag, offline):
|
||||
release_version = version()
|
||||
check_tag(tag, release_version)
|
||||
output = Path(output).absolute()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
epoch = int(os.environ.get('SOURCE_DATE_EPOCH', run('git', 'show', '-s', '--format=%ct', 'HEAD').strip()))
|
||||
with tempfile.TemporaryDirectory(prefix='ferese-source-') as temporary:
|
||||
tree = Path(temporary) / f'ferese-{release_version}'
|
||||
tree.mkdir()
|
||||
# Include new packaging files in local dry runs; release checkouts are clean.
|
||||
files = run('git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0')
|
||||
for filename in sorted(set(files) - {''}):
|
||||
path = ROOT / filename
|
||||
if not path.is_file():
|
||||
continue
|
||||
|
||||
destination = tree / filename
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, destination)
|
||||
|
||||
fetch = ['cargo', 'fetch', '--locked']
|
||||
if offline:
|
||||
fetch.append('--offline')
|
||||
|
||||
run(*fetch, cwd=tree)
|
||||
metadata = json.loads(run('cargo', 'metadata', '--locked', '--offline', '--format-version', '1', cwd=tree))
|
||||
licenses(tree / 'bundled-licenses', metadata)
|
||||
# cargo vendor cannot represent dpi 0.1.2 from both crates.io and Git.
|
||||
# Preserve source identities with an isolated, credential-free Cargo cache.
|
||||
cache = tree / 'cargo-home'
|
||||
for package in metadata['packages']:
|
||||
package_source = package['source']
|
||||
if package_source is None:
|
||||
continue
|
||||
|
||||
manifest = Path(package['manifest_path'])
|
||||
if package_source.startswith('registry+'):
|
||||
registry = manifest.parent.parent.name
|
||||
registry_home = manifest.parent.parent.parent.parent
|
||||
name = package['name']
|
||||
crate = f"{name}-{package['version']}.crate"
|
||||
for relative in (f'cache/{registry}/{crate}', f'index/{registry}/config.json'):
|
||||
original = registry_home / relative
|
||||
destination = cache / 'registry' / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(original, destination)
|
||||
|
||||
if len(name) == 1:
|
||||
index_path = f'1/{name}'
|
||||
elif len(name) == 2:
|
||||
index_path = f'2/{name}'
|
||||
elif len(name) == 3:
|
||||
index_path = f'3/{name[0]}/{name}'
|
||||
else:
|
||||
index_path = f'{name[:2]}/{name[2:4]}/{name}'
|
||||
|
||||
relative = f'index/{registry}/.cache/{index_path}'
|
||||
destination = cache / 'registry' / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(registry_home / relative, destination)
|
||||
elif package_source.startswith('git+'):
|
||||
checkout = next(parent for parent in manifest.parents if parent.parent.name == 'checkouts')
|
||||
original = checkout.parent.parent / 'db' / checkout.name
|
||||
destination = cache / 'git/db' / checkout.name
|
||||
if not destination.exists():
|
||||
shutil.copytree(original, destination)
|
||||
|
||||
# Cargo cannot fetch Git submodules in frozen mode. Carry the
|
||||
# completed checkout, including .cargo-ok and submodule objects.
|
||||
revision = next(parent for parent in manifest.parents if parent.parent == checkout)
|
||||
if (revision / '.gitmodules').exists():
|
||||
ready = cache / 'git/checkouts' / checkout.name / revision.name
|
||||
if not ready.exists():
|
||||
shutil.copytree(revision, ready)
|
||||
else:
|
||||
raise ValueError(f'Unsupported dependency source: {package_source}')
|
||||
|
||||
environment = dict(os.environ, CARGO_HOME=str(cache))
|
||||
run('cargo', 'metadata', '--frozen', '--offline', '--format-version', '1', cwd=tree, env=environment)
|
||||
# Cargo recreates these from the checked source archives and Git objects.
|
||||
for relative in ('registry/src',):
|
||||
shutil.rmtree(cache / relative, ignore_errors=True)
|
||||
|
||||
archive = output / f'ferese-{release_version}.tar.gz'
|
||||
with archive.open('wb') as raw, gzip.GzipFile(filename='', fileobj=raw, mode='wb', mtime=epoch) as zipped:
|
||||
with tarfile.open(fileobj=zipped, mode='w') as tar:
|
||||
for path in [tree, *sorted(tree.rglob('*'))]:
|
||||
info = tar.gettarinfo(str(path), arcname=str(path.relative_to(tree.parent)))
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ''
|
||||
info.mtime = epoch
|
||||
# The invoking user's umask must not change release permissions.
|
||||
info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644
|
||||
if info.isfile():
|
||||
with path.open('rb') as contents:
|
||||
tar.addfile(info, contents)
|
||||
else:
|
||||
tar.addfile(info)
|
||||
|
||||
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
recipe = (ROOT / 'packaging/arch/PKGBUILD.in').read_text()
|
||||
recipe = recipe.replace('@VERSION@', release_version.replace('-', ''))
|
||||
recipe = recipe.replace('@UPSTREAM_VERSION@', release_version).replace('@SHA256@', digest)
|
||||
(output / 'PKGBUILD').write_text(recipe)
|
||||
spec = (ROOT / 'packaging/fedora/ferese.spec.in').read_text()
|
||||
(output / 'ferese.spec').write_text(spec.replace('@VERSION@', release_version.replace('-', '~'))
|
||||
.replace('@UPSTREAM_VERSION@', release_version))
|
||||
(output / 'SHA256SUMS').write_text(f'{digest} {archive.name}\n')
|
||||
print(archive)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
stage_parser = commands.add_parser('stage')
|
||||
stage_parser.add_argument('--destdir', required=True)
|
||||
stage_parser.add_argument('--binary-dir', default=str(ROOT / 'target/release'))
|
||||
stage_parser.add_argument('--pam', choices=['fedora', 'arch'], required=True)
|
||||
source_parser = commands.add_parser('source')
|
||||
source_parser.add_argument('--output', default='target/dist')
|
||||
source_parser.add_argument('--tag')
|
||||
source_parser.add_argument('--offline', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.command == 'stage':
|
||||
stage(args.destdir, args.binary_dir, args.pam)
|
||||
else:
|
||||
source(args.output, args.tag, args.offline)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Package layout and release-input regressions; no root or network needed."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location('package', Path(__file__).resolve().parents[1] / 'package.py')
|
||||
PACKAGE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(PACKAGE)
|
||||
|
||||
|
||||
class PackageTests(unittest.TestCase):
|
||||
def test_release_tag_must_match_workspace_version(self):
|
||||
PACKAGE.check_tag('v0.1.0', '0.1.0')
|
||||
PACKAGE.check_tag('v0.2.0-rc.1', '0.2.0-rc.1')
|
||||
with self.assertRaises(ValueError):
|
||||
PACKAGE.check_tag('v0.2.0', '0.1.0')
|
||||
|
||||
def test_refuses_live_root(self):
|
||||
with self.assertRaises(ValueError):
|
||||
PACKAGE.stage('/', '/missing', 'fedora')
|
||||
|
||||
def test_refuses_existing_content_and_symlink_destinations(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
destination = root / 'dest'
|
||||
destination.mkdir()
|
||||
(destination / 'keep').write_text('existing')
|
||||
link = root / 'link'
|
||||
link.symlink_to(destination)
|
||||
for path in (destination, link):
|
||||
with self.assertRaises(ValueError):
|
||||
PACKAGE.stage(path, root / 'missing', 'fedora')
|
||||
|
||||
self.assertEqual((destination / 'keep').read_text(), 'existing')
|
||||
|
||||
def test_missing_binary_fails_before_staging(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with self.assertRaises(ValueError):
|
||||
PACKAGE.stage(root / 'dest', root / 'missing', 'fedora')
|
||||
|
||||
self.assertFalse((root / 'dest').exists())
|
||||
|
||||
def test_complete_package_layout_and_both_pam_policies(self):
|
||||
inventory = json.loads((PACKAGE.ROOT / 'scripts/installer/inventory.json').read_text())
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
binaries = root / 'bin'
|
||||
binaries.mkdir()
|
||||
for entry in inventory['files']:
|
||||
if entry['source'].startswith('target/release/'):
|
||||
(binaries / entry['target']).write_bytes(b'fixture')
|
||||
|
||||
for pam in ('fedora', 'arch'):
|
||||
with self.subTest(pam=pam), patch.object(PACKAGE, 'licenses'), patch.object(PACKAGE, 'run', return_value='{}'):
|
||||
dest = root / pam
|
||||
PACKAGE.stage(dest, binaries, pam)
|
||||
for entry in inventory['files']:
|
||||
if entry.get('link'):
|
||||
name = Path(entry['source']).name
|
||||
link = dest / 'usr/bin' / name
|
||||
self.assertEqual(link.readlink(), Path('/usr/libexec/ferese') / name)
|
||||
self.assertTrue((dest / str(link.readlink()).lstrip('/')).is_file())
|
||||
|
||||
for file in dest.rglob('*'):
|
||||
if file.is_file() and not file.is_symlink() and file.suffix in ('.desktop', '.service'):
|
||||
self.assertNotIn('/usr/local/', file.read_text())
|
||||
|
||||
self.assertFalse((dest / 'usr/local').exists())
|
||||
self.assertIn('system-auth', (dest / 'etc/pam.d/ferese-lock').read_text())
|
||||
wallpaper = dest / 'usr/libexec/ferese/wallpapers'
|
||||
self.assertEqual(wallpaper.readlink(), Path('/usr/share/ferese/wallpapers'))
|
||||
self.assertTrue((dest / 'usr/share/ferese/wallpapers/ferese.png').is_file())
|
||||
self.assertEqual((dest / 'usr/libexec/ferese/ferese-session').stat().st_mode & 0o777, 0o755)
|
||||
self.assertEqual((dest / 'usr/libexec/ferese').stat().st_mode & 0o777, 0o755)
|
||||
|
||||
def test_same_version_from_different_sources_keeps_both_licenses(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
packages = []
|
||||
for source in ('registry+first', 'registry+second'):
|
||||
folder = root / source.removeprefix('registry+')
|
||||
folder.mkdir()
|
||||
(folder / 'LICENSE').write_text(source)
|
||||
packages.append({'name': 'dpi', 'version': '0.1.2', 'source': source,
|
||||
'manifest_path': str(folder / 'Cargo.toml'),
|
||||
'license': 'MIT', 'license_file': None})
|
||||
|
||||
PACKAGE.licenses(root / 'notices', {'packages': packages})
|
||||
texts = {path.read_text() for path in (root / 'notices').glob('dpi-*/LICENSE')}
|
||||
self.assertEqual(texts, {'registry+first', 'registry+second'})
|
||||
|
||||
def test_dependency_license_report_contains_source_and_license(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'Cargo.toml').write_text('')
|
||||
(root / 'LICENSE-MIT').write_text('test license')
|
||||
metadata = {'packages': [{'name': 'fixture', 'version': '1.0.0',
|
||||
'source': 'registry+test', 'manifest_path': str(root / 'Cargo.toml'),
|
||||
'license': 'MIT', 'license_file': None}]}
|
||||
PACKAGE.licenses(root / 'notices', metadata)
|
||||
self.assertEqual(next((root / 'notices').glob('fixture-1.0.0-*/LICENSE-MIT')).read_text(), 'test license')
|
||||
report = json.loads((root / 'notices/dependencies.json').read_text())
|
||||
self.assertEqual(report[0]['license'], 'MIT')
|
||||
self.assertEqual(report[0]['source'], 'registry+test')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user