Skip to content

Commit 0ba120d

Browse files
committed
Identify when pixi toml file has updated relative to lock file
Pixi freshness check was giving the wrong error when the .toml file was changed but the .lock file wasn't checked in.
1 parent 2edf629 commit 0ba120d

5 files changed

Lines changed: 174 additions & 4 deletions

File tree

‎.github/workflows/ci-pixi-lockfile-freshness-check.yml‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,12 @@ jobs:
283283
--base-repaired "${base_repaired}"
284284
)
285285
fi
286+
# Matching repaired blobs do not establish base maintenance when
287+
# this PR edits the workspace's manifest but retains its stale lock.
288+
manifest_changed="$(git diff --name-only "${BASE_SHA}" HEAD -- "${manifest}/pixi.toml")"
289+
if [[ -n "${manifest_changed}" ]]; then
290+
classifier_args+=(--manifest-changed)
291+
fi
286292
classification="$(
287293
python3 ci/tools/classify_pixi_lockfile_freshness.py "${classifier_args[@]}"
288294
)"
@@ -302,8 +308,12 @@ jobs:
302308
303309
case "${classification}" in
304310
pr-induced)
305-
echo "::error title=PR-induced Pixi lockfile change::Lockfile '${lockfile}' became stale in this PR. Regenerate and commit it with pixi ${PIXI_VERSION}: pixi lock --manifest-path ${manifest}."
306-
echo "| \`${manifest}\` | PR-induced: regenerate and commit the lockfile in this PR. |" >> "${GITHUB_STEP_SUMMARY}"
311+
pr_reason="lockfile update required"
312+
if [[ -n "${manifest_changed}" && "${candidate_original}" == "${base_original}" ]]; then
313+
pr_reason="manifest changed without a lockfile update"
314+
fi
315+
echo "::error title=PR-induced Pixi lockfile change::Lockfile '${lockfile}' is stale in this PR (${pr_reason}). Regenerate and commit it with pixi ${PIXI_VERSION}: pixi lock --manifest-path ${manifest}."
316+
echo "| \`${manifest}\` | PR-induced: ${pr_reason}; regenerate and commit the lockfile in this PR. |" >> "${GITHUB_STEP_SUMMARY}"
307317
;;
308318
base-maintenance)
309319
echo "::error title=Base-maintenance Pixi lockfile change::Lockfile '${lockfile}' was already stale on the PR base; its original and repaired blobs match the candidate. Do not add this refresh to the feature PR. ${maintenance_annotation}"

‎ci/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ The pinned checkout action's
2525
[`README`](https://github.com/actions/checkout/blob/3d3c42e5aac5ba805825da76410c181273ba90b1/README.md#fetch-all-history-for-all-tags-and-branches)
2626
documents `fetch-depth: 0` for fetching all history, branches, and tags.
2727

28+
## Pixi Lockfile Freshness
29+
30+
The freshness check verifies every committed Pixi workspace against the pinned
31+
Pixi version. For PRs, a stale lockfile is attributed to the PR when its own
32+
`pixi.toml` changed but the lockfile was left unchanged, even if the base needs
33+
the same repair. The failure asks the author to regenerate and commit the
34+
lockfile in the PR. Changes to another workspace's manifest do not affect this
35+
attribution. A manifest edit that leaves its lockfile fresh still passes.
36+
2837
## Temporary Pixi Samples Lockfile Workaround
2938

3039
Pixi 0.73.0 checks PyPI dependencies that the `samples` environment explicitly

‎ci/tools/classify_pixi_lockfile_freshness.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@ def __post_init__(self) -> None:
3434
raise ValueError("a fresh lockfile cannot have different original and repaired blobs")
3535

3636

37-
def classify(candidate: LockfileCheck, base: LockfileCheck | None) -> Classification:
37+
def classify(candidate: LockfileCheck, base: LockfileCheck | None, *, manifest_changed: bool = False) -> Classification:
3838
"""Attribute a stale candidate lockfile using its base check, if any."""
3939
if not candidate.stale:
4040
raise ValueError("candidate lockfile must be stale")
4141
if base is None or not base.stale:
4242
return Classification.PR_INDUCED
43+
# A PR that edits this workspace's manifest owns its missing lockfile
44+
# update, even when the base happens to need the same repair.
45+
if manifest_changed and base.original_blob == candidate.original_blob:
46+
return Classification.PR_INDUCED
4347
if base.original_blob == candidate.original_blob and base.repaired_blob == candidate.repaired_blob:
4448
return Classification.BASE_MAINTENANCE
4549
return Classification.MIXED
@@ -52,6 +56,9 @@ def _parse_args() -> argparse.Namespace:
5256
parser.add_argument("--base-result", choices=("missing", "fresh", "stale"), required=True)
5357
parser.add_argument("--base-original")
5458
parser.add_argument("--base-repaired")
59+
parser.add_argument(
60+
"--manifest-changed", action="store_true", help="The workspace's pixi.toml differs from the PR base."
61+
)
5562
return parser.parse_args()
5663

5764

@@ -77,7 +84,7 @@ def main() -> int:
7784
repaired_blob=args.base_repaired,
7885
)
7986

80-
print(classify(candidate, base).value)
87+
print(classify(candidate, base, manifest_changed=args.manifest_changed).value)
8188
return 0
8289

8390

‎ci/tools/tests/test_classify_pixi_lockfile_freshness.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,23 @@ def test_matching_stale_blobs_are_base_maintenance():
3939
assert classify(candidate, base) is Classification.BASE_MAINTENANCE
4040

4141

42+
@pytest.mark.parametrize("base_repaired", ["shared-new", "base-new"])
43+
@pytest.mark.agent_authored(model="gpt-6")
44+
def test_changed_manifest_with_unchanged_stale_lockfile_is_pr_induced(base_repaired):
45+
candidate = _check(stale=True, original="shared-old", repaired="shared-new")
46+
base = _check(stale=True, original="shared-old", repaired=base_repaired)
47+
48+
assert classify(candidate, base, manifest_changed=True) is Classification.PR_INDUCED
49+
50+
51+
@pytest.mark.agent_authored(model="gpt-6")
52+
def test_changed_manifest_and_changed_stale_lockfile_remain_mixed():
53+
candidate = _check(stale=True, original="candidate-old", repaired="shared-new")
54+
base = _check(stale=True, original="base-old", repaired="shared-new")
55+
56+
assert classify(candidate, base, manifest_changed=True) is Classification.MIXED
57+
58+
4259
@pytest.mark.parametrize(
4360
("base_original", "base_repaired"),
4461
[
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Run the freshness workflow against local Git history and a mock Pixi."""
5+
6+
from __future__ import annotations
7+
8+
import os
9+
import shutil
10+
import subprocess
11+
from pathlib import Path
12+
13+
import pytest
14+
import yaml
15+
16+
ROOT = Path(__file__).resolve().parents[3]
17+
WORKFLOW = ROOT / ".github/workflows/ci-pixi-lockfile-freshness-check.yml"
18+
19+
20+
def _git(repo, *args):
21+
return subprocess.run( # noqa: S603 - arguments are passed without a shell.
22+
["git", "-C", str(repo), *args], # noqa: S607
23+
check=True,
24+
capture_output=True,
25+
text=True,
26+
).stdout.strip()
27+
28+
29+
def _commit(repo):
30+
_git(repo, "add", ".")
31+
_git(repo, "-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "test")
32+
return _git(repo, "rev-parse", "HEAD")
33+
34+
35+
def _run_check(tmp_path, manifest, changed_manifest, *, stale=True, known_defect=False):
36+
repo = tmp_path / "repo"
37+
repo.mkdir()
38+
_git(repo, "init", "-q")
39+
_git(repo, "config", "commit.gpgsign", "false")
40+
tools = repo / "ci/tools"
41+
tools.mkdir(parents=True)
42+
for name in ("list_pixi_workspaces.py", "classify_pixi_lockfile_freshness.py"):
43+
shutil.copyfile(ROOT / "ci/tools" / name, tools / name)
44+
# The exact defect detector is covered separately; exercise its workflow gate.
45+
(tools / "check_pixi_samples_source_pruning.py").write_text(
46+
f"raise SystemExit({0 if known_defect else 1})\n", encoding="utf-8"
47+
)
48+
for workspace in {".", manifest, changed_manifest}:
49+
directory = repo / workspace
50+
directory.mkdir(parents=True, exist_ok=True)
51+
(directory / "pixi.toml").write_text('[workspace]\nchannels = ["conda-forge"]\n', encoding="utf-8")
52+
content = "stale\n" if stale and workspace == manifest else "fresh\n"
53+
(directory / "pixi.lock").write_text(content, encoding="utf-8")
54+
base_sha = _commit(repo)
55+
with (repo / changed_manifest / "pixi.toml").open("a", encoding="utf-8") as stream:
56+
stream.write('\n[tasks]\nexample = "echo example"\n')
57+
_commit(repo)
58+
59+
bin_dir = tmp_path / "bin"
60+
bin_dir.mkdir()
61+
pixi = bin_dir / "pixi"
62+
pixi.write_text(
63+
"#!/usr/bin/env python3\n"
64+
"import sys\n"
65+
"from pathlib import Path\n"
66+
"lockfile = Path(sys.argv[-1]) / 'pixi.lock'\n"
67+
"if lockfile.read_text() == 'stale\\n':\n"
68+
" lockfile.write_text('repaired\\n')\n"
69+
" sys.exit(1)\n",
70+
encoding="utf-8",
71+
)
72+
pixi.chmod(0o755)
73+
summary = tmp_path / "summary.md"
74+
output = tmp_path / "output.txt"
75+
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
76+
step = next(step for step in workflow["jobs"]["lockfile-fresh"]["steps"] if step.get("id") == "check")
77+
result = subprocess.run( # noqa: S603 - run trusted workflow code with local fixtures and no network.
78+
["bash", "--noprofile", "--norc", "-euo", "pipefail", "-c", step["run"]], # noqa: S607
79+
cwd=repo,
80+
env={
81+
**os.environ,
82+
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
83+
"BASE_SHA": base_sha,
84+
"EVENT_NAME": "pull_request",
85+
"MAINTENANCE_KIND": "workflow",
86+
"MAINTENANCE_URL": "https://example.com/refresh",
87+
"PIXI_VERSION": "v0.73.0",
88+
"WORKSPACE_TIMEOUT": "10s",
89+
"RUNNER_TEMP": str(tmp_path),
90+
"GITHUB_STEP_SUMMARY": str(summary),
91+
"GITHUB_OUTPUT": str(output),
92+
},
93+
capture_output=True,
94+
text=True,
95+
timeout=30,
96+
)
97+
return result, summary.read_text(encoding="utf-8")
98+
99+
100+
@pytest.mark.parametrize("manifest", [".", "nested/workspace", "cuda_core"])
101+
@pytest.mark.agent_authored(model="gpt-6")
102+
def test_manifest_edit_without_lock_update_reports_pr_remediation(tmp_path, manifest):
103+
result, summary = _run_check(tmp_path, manifest, manifest, known_defect=manifest == "cuda_core")
104+
105+
assert result.returncode == 1, result.stdout + result.stderr
106+
assert "manifest changed without a lockfile update" in summary
107+
assert "regenerate and commit the lockfile in this PR" in summary
108+
assert "Base maintenance:" not in summary
109+
assert "Do not add this refresh" not in result.stdout
110+
assert "Known inherited Pixi samples lockfile bug" not in result.stdout
111+
112+
113+
@pytest.mark.agent_authored(model="gpt-6")
114+
def test_other_workspace_manifest_edit_keeps_base_maintenance_attribution(tmp_path):
115+
result, summary = _run_check(tmp_path, "nested/workspace", ".")
116+
117+
assert result.returncode == 1, result.stdout + result.stderr
118+
assert "Base maintenance:" in summary
119+
assert "PR-induced:" not in summary
120+
121+
122+
@pytest.mark.agent_authored(model="gpt-6")
123+
def test_fresh_lockfile_allows_task_only_manifest_edit(tmp_path):
124+
result, summary = _run_check(tmp_path, "nested/workspace", "nested/workspace", stale=False)
125+
126+
assert result.returncode == 0, result.stdout + result.stderr
127+
assert "| `nested/workspace` | Fresh |" in summary

0 commit comments

Comments
 (0)