Skip to content

Commit 864cd6a

Browse files
committed
sync: don't promote on vendor.tar.* drift in branch content checks
The --branch-from content check compares raw file md5s against OBS. For packages with a cargo_vendor service whose upstream ships no Cargo.lock (pgvectorscale), cargo vendor re-resolves the crate graph on every run, so vendor.tar.gz bytes track the crates.io state at generation time and differ between machines and dates even when no real input changed (verified: identical back-to-back local runs, but OBS rev5 vendored camino-1.2.4 while a fresh run vendors camino-1.2.5). Seen on percona/obs-packaging PR #5: a PR touching only ppg:staging:18 packages promoted percona-pgvectorscale in staging:16/17/18:extras because the content check reported 'vendor.tar.gz differs' (the gate before it fell through to the content check because the OBS sync comments record SHAs from a fork checkout that do not exist in the CI clone). Compare vendor.tar.* by presence instead of bytes when the package declares a cargo_vendor service and both sides have the archive: it is a derived artifact of the other uploaded files, so if those all match, a vendor byte difference is pure resolution drift and must not flip an aggregate decision to promote. A vendor archive missing from either side is still a mismatch. The comparison is extracted into _content_mismatches so it is unit-testable and reports every differing file instead of stopping at the first. Signed-off-by: Ricardo Dias <ricardo.dias@percona.com>
1 parent c3a9dfb commit 864cd6a

3 files changed

Lines changed: 130 additions & 15 deletions

File tree

percona_obs/cmd_sync.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
)
9191
from .services import (
9292
_copy_local_packaging,
93+
_has_cargo_vendor_service,
9394
_has_runnable_services,
9495
_run_local_services,
9596
upstream_scm_refs,
@@ -119,6 +120,9 @@
119120
# layout ("…:containers") intentionally does not match and is never filtered.
120121
_CONTAINER_SUBPROJ_RE = re.compile(r":containers:([^:]+)$")
121122

123+
# Matches the vendored-crates archive produced by the cargo_vendor service.
124+
_VENDOR_TAR_RE = re.compile(r"^vendor\.tar\.[a-z0-9]+$")
125+
122126

123127
_OBS_SUBSTITUTABLE = {"_service", "_aggregate", "_link"}
124128

@@ -435,6 +439,34 @@ def _is_link_package(obs_dir: Path) -> bool:
435439
return (obs_dir / "_link").is_file()
436440

437441

442+
def _content_mismatches(
443+
local_md5s: dict[str, str],
444+
obs_md5s: dict[str, str],
445+
ignore_vendor: bool,
446+
) -> list[str]:
447+
"""Return the file names whose content differs between local and OBS.
448+
449+
With *ignore_vendor*, a vendor.tar.* present on both sides is compared by
450+
presence only, never by bytes. cargo_vendor output is a function of the
451+
other uploaded files plus the crates.io state at generation time: upstream
452+
projects without a committed Cargo.lock (e.g. pgvectorscale) re-resolve
453+
the crate graph on every run, so its bytes drift between machines and
454+
dates even when no real input changed. A vendor-only difference must
455+
therefore not flip a branch decision to promote. A vendor archive
456+
missing from either side is still a mismatch.
457+
"""
458+
mismatches = [name for name, md5 in local_md5s.items() if obs_md5s.get(name) != md5]
459+
mismatches += [name for name in obs_md5s if name not in local_md5s]
460+
if ignore_vendor:
461+
both = set(local_md5s) & set(obs_md5s)
462+
mismatches = [
463+
name
464+
for name in mismatches
465+
if not (_VENDOR_TAR_RE.match(name) and name in both)
466+
]
467+
return sorted(mismatches)
468+
469+
438470
def _content_matches_branch(
439471
apiurl: str,
440472
branch_project: str,
@@ -515,21 +547,17 @@ def _content_matches_branch(
515547
if f.is_file():
516548
local_md5s[f.name] = hashlib.md5(f.read_bytes()).hexdigest()
517549

518-
for fname, local_md5 in sorted(local_md5s.items()):
519-
if obs_md5s.get(fname) != local_md5:
520-
logger.debug(
521-
f"content check: {fname} differs {branch_project}/{package_name}"
522-
)
523-
return False
524-
525-
for fname in obs_md5s:
526-
if fname not in local_md5s:
527-
logger.debug(
528-
f"content check: {fname} in OBS but not local {branch_project}/{package_name}"
529-
)
530-
return False
531-
532-
return True
550+
mismatches = _content_mismatches(
551+
local_md5s,
552+
obs_md5s,
553+
ignore_vendor=run_services and _has_cargo_vendor_service(service_file),
554+
)
555+
for fname in mismatches:
556+
detail = "differs" if fname in local_md5s else "in OBS but not local"
557+
logger.debug(
558+
f"content check: {fname} {detail} {branch_project}/{package_name}"
559+
)
560+
return not mismatches
533561
finally:
534562
shutil.rmtree(combined, ignore_errors=True)
535563
if workdir is not None:

percona_obs/services.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ def _has_runnable_services(service_file: Path) -> bool:
7676
)
7777

7878

79+
def _has_cargo_vendor_service(service_file: Path) -> bool:
80+
"""Return True if *service_file* declares a cargo_vendor service."""
81+
try:
82+
root = ET.parse(service_file).getroot()
83+
except (ET.ParseError, OSError):
84+
return False
85+
return any(svc.get("name") == "cargo_vendor" for svc in root.findall("service"))
86+
87+
7988
def _get_upstream_obs_scm_info(
8089
service_file: Path,
8190
) -> tuple[str, str, str] | None:

tests/test_vendor_content_check.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Unit tests for vendor-aware content comparison (percona_obs.cmd_sync).
2+
3+
Reproduces the percona/obs-packaging PR #5 bug: pgvectorscale ships no
4+
Cargo.lock, so cargo_vendor re-resolves the crate graph on every run and
5+
vendor.tar.gz bytes drift with the crates.io state at generation time. A
6+
branch-from content check comparing raw md5s then reported "vendor.tar.gz
7+
differs" for packages whose real inputs were untouched, promoting
8+
percona-pgvectorscale in every extras project on unrelated PRs.
9+
"""
10+
11+
from percona_obs.cmd_sync import _content_mismatches
12+
from percona_obs.services import _has_cargo_vendor_service
13+
14+
SPEC = "percona-pgvectorscale.spec"
15+
SRC = "percona-pgvectorscale_16-0.9.0.tar.gz"
16+
VENDOR = "vendor.tar.gz"
17+
18+
19+
def test_vendor_only_drift_ignored_for_cargo_packages():
20+
local = {SPEC: "a", SRC: "b", VENDOR: "local-bytes"}
21+
obs = {SPEC: "a", SRC: "b", VENDOR: "obs-bytes"}
22+
assert _content_mismatches(local, obs, ignore_vendor=True) == []
23+
24+
25+
def test_vendor_drift_reported_without_cargo_vendor_service():
26+
local = {SPEC: "a", VENDOR: "local-bytes"}
27+
obs = {SPEC: "a", VENDOR: "obs-bytes"}
28+
assert _content_mismatches(local, obs, ignore_vendor=False) == [VENDOR]
29+
30+
31+
def test_real_change_still_reported_alongside_vendor_drift():
32+
local = {SPEC: "changed", SRC: "b", VENDOR: "local-bytes"}
33+
obs = {SPEC: "a", SRC: "b", VENDOR: "obs-bytes"}
34+
assert _content_mismatches(local, obs, ignore_vendor=True) == [SPEC]
35+
36+
37+
def test_vendor_missing_on_obs_is_a_mismatch():
38+
local = {SPEC: "a", VENDOR: "local-bytes"}
39+
obs = {SPEC: "a"}
40+
assert _content_mismatches(local, obs, ignore_vendor=True) == [VENDOR]
41+
42+
43+
def test_vendor_missing_locally_is_a_mismatch():
44+
local = {SPEC: "a"}
45+
obs = {SPEC: "a", VENDOR: "obs-bytes"}
46+
assert _content_mismatches(local, obs, ignore_vendor=True) == [VENDOR]
47+
48+
49+
def test_other_compressions_ignored_too():
50+
local = {SPEC: "a", "vendor.tar.xz": "x", "vendor.tar.zst": "y"}
51+
obs = {SPEC: "a", "vendor.tar.xz": "p", "vendor.tar.zst": "q"}
52+
assert _content_mismatches(local, obs, ignore_vendor=True) == []
53+
54+
55+
def test_identical_content_has_no_mismatches():
56+
local = {SPEC: "a", SRC: "b"}
57+
obs = {SPEC: "a", SRC: "b"}
58+
assert _content_mismatches(local, obs, ignore_vendor=False) == []
59+
60+
61+
def test_has_cargo_vendor_service(tmp_path):
62+
with_vendor = tmp_path / "with" / "_service"
63+
with_vendor.parent.mkdir()
64+
with_vendor.write_text(
65+
"<services>"
66+
'<service mode="buildtime" name="cargo_vendor">'
67+
'<param name="compression">gz</param>'
68+
"</service>"
69+
"</services>"
70+
)
71+
without_vendor = tmp_path / "without" / "_service"
72+
without_vendor.parent.mkdir()
73+
without_vendor.write_text(
74+
'<services><service mode="buildtime" name="tar" /></services>'
75+
)
76+
assert _has_cargo_vendor_service(with_vendor) is True
77+
assert _has_cargo_vendor_service(without_vendor) is False
78+
assert _has_cargo_vendor_service(tmp_path / "missing" / "_service") is False

0 commit comments

Comments
 (0)