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:
Samuel Onoja authored and GitHub committed 2026-10-11 14:16:00 +01:00
1 parent 587602b493
commit 1b9fabbbe4
25 files changed
+1019 -170

No files matched your search

+1
View File
@@ -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'),
+2
View File
@@ -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):
-5
View File
@@ -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}",
+282
View File
@@ -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()
+112
View File
@@ -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()