1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
|
--- /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'(?<!\d)9{4,}(?!\d)', version):
+ result.add(version)
+ return result
+
+
+def shadowed_base_updates(data: dict, work: Path, tree: Path) -> 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
}
|