--- /dev/null +++ b/src/foundry/repository_freshness.py @@ -0,0 +1,56 @@ +"""Explain release versions hidden by deliberate whole-package overrides.""" +from __future__ import annotations + +from pathlib import Path +import re + +from .merge import _best_version, _compare_versions, rel_filtered + + +def release_versions(root: Path, package: str, filters: list[str]) -> set[str]: + prefix = package.rsplit('/', 1)[-1] + '-' + result = set() + for path in (root / package).glob('*.ebuild'): + if not path.name.startswith(prefix) or rel_filtered(f'{package}/{path.name}', filters): + continue + version = path.name[len(prefix):-len('.ebuild')] + # Live ebuilds (9999, 0.11.9999, etc.) do not replace release coverage. + if version and not re.search(r'(? list[dict]: + """Compare the composed version set, after applying source precedence. + + This is an availability diagnostic; slots, keywords, masks and buildability + remain Portage policy. Local overrides are never silently discarded. + """ + sources = [source for source in data.get('sources', []) if source.get('enabled', True)] + base = next((source for source in sources + if source['name'] == data.get('composition', {}).get('base')), None) + if base is None: + return [] + owners = {} + for source in sources: + if not source.get('first_party') or source is base: + continue + for path in (work / source['name']).glob('*/*/*.ebuild'): + package = path.parent.relative_to(work / source['name']).as_posix() + if (not rel_filtered(package, source.get('filter', [])) + and (not source.get('select') or package in source['select'])): + owners[package] = source['name'] + findings = [] + for package, source in sorted(owners.items()): + upstream = _best_version(release_versions(work / base['name'], package, base.get('filter', []))) + composed = _best_version(release_versions(tree, package, [])) + if upstream and (not composed or _compare_versions(upstream, composed) > 0): + findings.append({ + 'severity': 'warning', 'code': 'shadowed-base-update', + 'package': package, 'source': source, 'base': base['name'], + 'base_version': upstream, 'composed_version': composed or None, + 'message': f'{package}: first-party source {source} hides {base["name"]} ' + f'release {upstream}; composed release is {composed or "absent"}. ' + 'Update the local recipe or exclude the obsolete package override.', + }) + return findings --- a/src/foundry/repository_composition.py +++ b/src/foundry/repository_composition.py @@ -3,6 +3,7 @@ from pathlib import Path, PurePosixPath import shutil +import sys from .commands import CommandExecutor from .merge import (CopyFile, MergeRepo, apply_profile_overrides, gentoo_prune_protected_packages, @@ -10,6 +11,7 @@ normalize_repo, normalize_repo_identity, prune_gentoo_package_overlap, run_mass_edits, stamp_metadata) from .transition import atomic_text, tree_digest +from .repository_freshness import shadowed_base_updates def relative_path(value: str) -> str: @@ -102,6 +104,14 @@ step["identity_files"] = normalize_repo_identity(destination, data["repository"]["name"]) elif engine != "portage": raise ValueError(f"unknown repository composition engine: {engine}") + with op.step("check for shadowed base updates") as step: + findings = shadowed_base_updates(data, work, destination) + step["findings"] = findings + warnings = [finding["message"] for finding in findings] + op.payload.setdefault("warnings", []).extend(warnings) + if not op.quiet: + for warning in warnings: + print(f"Warning: {warning}", file=sys.stderr, flush=True) return stats --- /dev/null +++ b/tests/test_repository_freshness.py @@ -0,0 +1,92 @@ +from contextlib import redirect_stderr +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +from foundry.repository_composition import compose +from foundry.repository_freshness import shadowed_base_updates +from foundry.transition import Operation +from test_frontends import runtime_at +from test_repository_defaults import put + + +class RepositoryFreshnessTests(unittest.TestCase): + def setUp(self): + self.temp = TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.work = self.root / 'work' + self.tree = self.root / 'tree' + self.data = { + 'composition': {'engine': 'portage', 'base': 'gentoo'}, + 'sources': [ + {'name': 'gentoo', 'role': 'seed'}, + {'name': 'local', 'role': 'overlay', 'first_party': True}, + ], + } + self.package('gentoo', '1.10') + self.package('local', '1.9') + put(self.tree, 'gui-apps/accessory/accessory-1.9.ebuild', 'EAPI=8\n') + + def package(self, source, version): + put(self.work / source, f'gui-apps/accessory/accessory-{version}.ebuild', 'EAPI=8\n') + + def findings(self): + return shadowed_base_updates(self.data, self.work, self.tree) + + def test_newer_base_is_reported_with_source_and_versions(self): + finding, = self.findings() + self.assertEqual(finding['package'], 'gui-apps/accessory') + self.assertEqual(finding['source'], 'local') + self.assertEqual(finding['base_version'], '1.10') + self.assertEqual(finding['composed_version'], '1.9') + self.assertEqual(finding['severity'], 'warning') + + def test_live_ebuild_does_not_hide_a_missing_release(self): + self.package('gentoo', '9999') + put(self.tree, 'gui-apps/accessory/accessory-9999.ebuild', 'EAPI=8\n') + self.assertEqual(self.findings()[0]['base_version'], '1.10') + (self.tree / 'gui-apps/accessory/accessory-1.9.ebuild').unlink() + self.assertEqual(self.findings()[0]['composed_version'], None) + + def test_source_selection_exclusion_and_disabled_sources_are_respected(self): + local = self.data['sources'][1] + for setting in [{'select': ['gui-apps/other']}, {'filter': ['gui-apps/accessory']}, + {'enabled': False}, {'first_party': False}]: + with self.subTest(setting=setting): + self.data['sources'][1] = dict(local, **setting) + self.assertEqual(self.findings(), []) + + def test_filtered_base_versions_do_not_warn(self): + self.data['sources'][0]['filter'] = ['gui-apps/accessory/accessory-1.10.ebuild'] + self.assertEqual(self.findings(), []) + + def test_newer_or_equal_local_revision_does_not_warn(self): + for version in ['1.10', '1.10-r1', '2.0']: + with self.subTest(version=version): + put(self.tree, f'gui-apps/accessory/accessory-{version}.ebuild', 'EAPI=8\n') + self.assertEqual(self.findings(), []) + + def test_last_selected_local_source_is_attributed(self): + self.package('later', '1.8') + self.data['sources'].append({'name': 'later', 'first_party': True}) + self.assertEqual(self.findings()[0]['source'], 'later') + + def test_composition_surfaces_warning_without_overwriting_local_fix(self): + runtime = runtime_at(self.root) + for source in ('gentoo', 'local'): + put(self.work / source, 'profiles/categories', 'gui-apps\n') + output = StringIO() + with redirect_stderr(output): + with Operation(runtime, 'crucible', 'example', 'update') as op: + compose(self.data, self.work, self.tree, op) + result = op.finish() + self.assertEqual(len(result['warnings']), 1) + self.assertIn('1.10', output.getvalue()) + self.assertTrue((self.tree / 'gui-apps/accessory/accessory-1.9.ebuild').exists()) + self.assertFalse((self.tree / 'gui-apps/accessory/accessory-1.10.ebuild').exists()) + + +if __name__ == '__main__': + unittest.main() --- a/docs/crucible-upstream.md +++ b/docs/crucible-upstream.md @@ -149,6 +149,15 @@ themselves. Use `--no-publish` on the update to inspect its candidate when the project normally publishes automatically. +First-party sources own whole package directories, including their ebuild +version sets. A local package can therefore hide newer Gentoo releases even +when the upstream fetch succeeds. Updates report these cases as warnings on +stderr and in the update job's `warnings` and `check for shadowed base updates` +step. Update the local recipe, preserving required patches, or exclude that +package from the obsolete source so the base supplies it. Live ebuilds do not +count as release coverage. This comparison uses the highest non-live version; +it does not evaluate slots, keywords, masks, or package buildability. + ## Current status and Beosrepo compatibility `crucible status REPO` shows the published release, candidate, actual Git tip, --- a/src/foundry/data/openrc/foundryd +++ b/src/foundry/data/openrc/foundryd @@ -4,11 +4,13 @@ command="${FOUNDRYD_COMMAND:-/usr/bin/foundryd}" command_args="${FOUNDRYD_ARGS:-}" command_background=true +output_logger="logger -t foundryd -p daemon.info" +error_logger="logger -t foundryd -p daemon.err" pidfile="${FOUNDRYD_PIDFILE:-/run/foundry/foundryd.pid}" directory="/" depend() { - need localmount + need localmount logger after bootmisc }