diff --git a/.github/workflows/ci-pixi-lockfile-freshness-check.yml b/.github/workflows/ci-pixi-lockfile-freshness-check.yml index 76027d37161..90003682704 100644 --- a/.github/workflows/ci-pixi-lockfile-freshness-check.yml +++ b/.github/workflows/ci-pixi-lockfile-freshness-check.yml @@ -6,8 +6,9 @@ # its bytes are not what the pinned pixi version generates. Pull requests also # check stale workspaces at the base SHA so diagnostics can distinguish changes # introduced by the PR from maintenance already needed on the base branch. -# Attribution changes remediation only; the aggregate job remains strict for -# every stale classification. See #2298 and #2926. +# Attribution changes remediation only. A temporary, exact-match exception +# covers Pixi 0.73.0 pruning the samples environment's local source references; +# every other stale classification remains strict. See #2298 and #2926. name: "CI: pixi lockfile freshness check" @@ -28,6 +29,8 @@ on: - "**/pixi.lock" - "**/pyproject.toml" - "ci/tools/classify_pixi_lockfile_freshness.py" + - "ci/tools/check_pixi_samples_source_pruning.py" + - "ci/tools/tests/test_check_pixi_samples_source_pruning.py" - "ci/tools/list_pixi_workspaces.py" - "ci/pixi-version.env" - ".github/actions/setup-pixi/action.yml" @@ -195,7 +198,35 @@ jobs: continue fi + known_samples_pruning=0 + if [[ "${manifest}" == "cuda_core" ]]; then + defect_status=0 + python3 ci/tools/check_pixi_samples_source_pruning.py \ + --original-blob "${candidate_original}" \ + --lockfile "${lockfile}" \ + --manifest-path "${manifest}/pixi.toml" \ + --pixi-version "${PIXI_VERSION}" \ + --check-status "${check_status}" \ + || defect_status=$? + if ((defect_status == 0)); then + known_samples_pruning=1 + elif ((defect_status != 1)); then + echo "::error::Could not determine whether '${lockfile}' matches the known Pixi samples-source pruning defect. Resolve the detector error above and rerun." + echo "| \`${manifest}\` | Operational error: known-defect detector failed. |" >> "${GITHUB_STEP_SUMMARY}" + failed=1 + echo "::endgroup::" + continue + fi + fi + if [[ "${EVENT_NAME}" != "pull_request" ]]; then + if ((known_samples_pruning != 0)); then + echo "::warning title=Known Pixi samples lockfile bug::Pixi ${PIXI_VERSION} removed only the nine samples local-source references. Keeping the committed references; all other lockfile changes remain strict. Remove this temporary exception once the upstream fix is released and verified." + echo "| \`${manifest}\` | Known Pixi samples-source pruning bug: warning only; committed local-source references retained. |" >> "${GITHUB_STEP_SUMMARY}" + git cat-file blob "${candidate_original}" > "${lockfile}" + echo "::endgroup::" + continue + fi echo "::error title=Stale Pixi lockfile::Lockfile '${lockfile}' is stale. Regenerate and commit it with pixi ${PIXI_VERSION}: pixi lock --manifest-path ${manifest}." echo "| \`${manifest}\` | Stale: regenerate and commit the lockfile. |" >> "${GITHUB_STEP_SUMMARY}" failed=1 @@ -256,6 +287,19 @@ jobs: python3 ci/tools/classify_pixi_lockfile_freshness.py "${classifier_args[@]}" )" + # Do not let a matching solver result hide PR-specific manifest + # changes, even if they happen to produce the same lockfile blobs. + if ((known_samples_pruning != 0)) && \ + [[ "${classification}" == "base-maintenance" ]] && \ + git diff --quiet "${BASE_SHA}" HEAD -- \ + ':(glob)**/pixi.toml' ':(glob)**/pyproject.toml' ci/pixi-version.env; then + echo "::warning title=Known inherited Pixi samples lockfile bug::Base and candidate reproduce the same nine-reference samples-source pruning defect, with no PR changes to lock inputs. Keeping the committed local-source references; all other lockfile changes remain strict. Remove this temporary exception once the upstream fix is released and verified." + echo "| \`${manifest}\` | Known inherited Pixi samples-source pruning bug: warning only; no lock-input changes in this PR. Committed local-source references retained. |" >> "${GITHUB_STEP_SUMMARY}" + git cat-file blob "${candidate_original}" > "${lockfile}" + echo "::endgroup::" + continue + fi + case "${classification}" in pr-induced) 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}." diff --git a/.github/workflows/ci-pixi-source-test.yml b/.github/workflows/ci-pixi-source-test.yml index 38d2e5233d2..5407c4e9e98 100644 --- a/.github/workflows/ci-pixi-source-test.yml +++ b/.github/workflows/ci-pixi-source-test.yml @@ -62,10 +62,13 @@ defaults: shell: bash --noprofile --norc -xeuo pipefail {0} env: - # Install from the committed lockfile only. Covers nested `pixi run` calls - # from root pixi.toml tasks; a stale lock must fail rather than being - # refreshed in CI (#2298). Equivalent to `pixi run --locked`. - PIXI_LOCKED: "true" + # Temporarily bypass Pixi's workspace-wide freshness check: the unrelated + # samples environment oscillates because excluded PyPI dependencies are + # still checked. Frozen mode keeps the committed solution and source builds, + # including nested `pixi run` calls. The separate freshness workflow rejects + # every change except the exact known samples-source pruning defect. + # Restore PIXI_LOCKED after the upstream fix is released and verified. + PIXI_FROZEN: "true" jobs: # ── PR guard: CPU-only build + import + placement smoke ── diff --git a/ci/README.md b/ci/README.md index 7d0f0bbfdb7..51d39a2024d 100644 --- a/ci/README.md +++ b/ci/README.md @@ -25,6 +25,31 @@ The pinned checkout action's [`README`](https://github.com/actions/checkout/blob/3d3c42e5aac5ba805825da76410c181273ba90b1/README.md#fetch-all-history-for-all-tags-and-branches) documents `fetch-depth: 0` for fetching all history, branches, and tags. +## Temporary Pixi Samples Lockfile Workaround + +Pixi 0.73.0 checks PyPI dependencies that the `samples` environment explicitly +excludes, then drops its local Conda source references during a PyPI-only +refresh. The next refresh restores them, so repeated lock commands do not +converge. A general maintenance refresh cannot repair this defect. + +Source-build CI temporarily uses `PIXI_FROZEN=true`: it builds the checkout's +source packages using the committed dependency solution without triggering +Pixi's workspace-wide freshness check. + +The separate freshness workflow remains strict except for one exact pattern: +Pixi 0.73.0 exits 1 and removes precisely the nine `samples` source references, +with every other lockfile byte unchanged. The detector also verifies the +expected manifest overrides, local paths, platforms, and source-record +definitions. It rejects the reverse cycle, which would add missing references. +For PRs, the base must produce identical original and repaired blobs, and no +`pixi.toml`, `pyproject.toml`, or Pixi-version input may differ from the base. +Recognized cases emit a warning and restore the committed references. + +Remove the detector and exception, and restore `PIXI_LOCKED=true`, once a +released Pixi passes repeated lock and check commands without changing either +the reduced upstream reproducer or our full workspaces. The refresh workflow +still refuses to open an unstable lockfile-refresh PR. + ## Repository Customizations The workflows in this repository use the `CI_CUSTOMIZATIONS_*` namespace for diff --git a/ci/tools/check_pixi_samples_source_pruning.py b/ci/tools/check_pixi_samples_source_pruning.py new file mode 100644 index 00000000000..1ec054f0171 --- /dev/null +++ b/ci/tools/check_pixi_samples_source_pruning.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Recognize one temporary Pixi 0.73.0 source-pruning defect, failing closed.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +import tomllib + +LOCAL_PACKAGES = { + "cuda-bindings": "../cuda_bindings", + "cuda-core": ".", + "cuda-pathfinder": "../cuda_pathfinder", +} +PLATFORMS = {"p1": "linux-64", "p2": "linux-aarch64", "p3": "win-64"} +SOURCE_REFERENCE = re.compile( + r" - conda_source: (cuda-bindings|cuda-core|cuda-pathfinder)\[([0-9a-f]{8})\] @ (\S+)\n" +) + + +def is_known_samples_source_pruning(original: str, repaired: str) -> bool: + """Accept only removal of the nine known samples references, with nothing else changed. + + This deliberately understands only the pinned version's exact serialization. + Source record definitions remain checked byte-for-byte along with the rest of + the lockfile; missing definitions and the reverse (addition) cycle are rejected. + """ + lines = original.splitlines(keepends=True) + if not lines or lines[0] != "version: 7\n" or lines.count("environments:\n") != 1: + return False + for alias, platform in PLATFORMS.items(): + if original.count(f"- name: {alias}\n subdir: {platform}\n") != 1: + return False + + environment_start = lines.index("environments:\n") + 1 + environment_end = next( + (index for index in range(environment_start, len(lines)) if not lines[index].startswith(" ")), + len(lines), + ) + sample_headers = [index for index in range(environment_start, environment_end) if lines[index] == " samples:\n"] + if len(sample_headers) != 1: + return False + samples_start = sample_headers[0] + 1 + samples_end = next( + (index for index in range(samples_start, environment_end) if not lines[index].startswith(" ")), + environment_end, + ) + if any(lines[samples_start:samples_end].count(f" {alias}:\n") != 1 for alias in PLATFORMS): + return False + + platform = None + seen: set[tuple[str, str]] = set() + removed: set[int] = set() + for index in range(samples_start, samples_end): + line = lines[index] + platform_match = re.fullmatch(r" ([^\s:]+):\n", line) + if platform_match: + platform = platform_match[1] if platform_match[1] in PLATFORMS else None + reference = SOURCE_REFERENCE.fullmatch(line) + if reference is None: + continue + name, identifier, path = reference.groups() + if platform is None or path != LOCAL_PACKAGES[name] or (platform, name) in seen: + return False + definition = f"- conda_source: {name}[{identifier}] @ {path}\n" + if lines[environment_end:].count(definition) != 1: + return False + seen.add((platform, name)) + removed.add(index) + + expected = {(platform, name) for platform in PLATFORMS for name in LOCAL_PACKAGES} + return seen == expected and "".join(line for index, line in enumerate(lines) if index not in removed) == repaired + + +def _manifest_matches(manifest: dict) -> bool: + features = manifest.get("feature", {}) + if not isinstance(features, dict): + return False + samples = features.get("samples", {}) + local_deps = features.get("local-deps", {}) + environments = manifest.get("environments", {}) + if not all(isinstance(table, dict) for table in (samples, local_deps, environments)): + return False + options = samples.get("pypi-options", {}) + sample_dependencies = samples.get("dependencies", {}) + local_dependencies = local_deps.get("dependencies", {}) + if not all(isinstance(table, dict) for table in (options, sample_dependencies, local_dependencies)): + return False + overrides = options.get("dependency-overrides", {}) + if overrides != {name: {"version": "*", "env-markers": "python_version < '0'"} for name in LOCAL_PACKAGES}: + return False + if sample_dependencies.get("cuda-core") != {"path": "."}: + return False + if any( + local_dependencies.get(name) != {"path": path} for name, path in LOCAL_PACKAGES.items() if name != "cuda-core" + ): + return False + environment = environments.get("samples", {}) + return isinstance(environment, dict) and environment.get("features") == ["cu13", "samples", "local-deps"] + + +def main(argv: list[str] | None = None) -> int: + """Exit 0 for the known defect, 1 otherwise, and 2 for an operational error.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--original-blob", required=True) + parser.add_argument("--lockfile", type=Path, required=True) + parser.add_argument("--manifest-path", type=Path, required=True) + parser.add_argument("--pixi-version", required=True) + parser.add_argument("--check-status", type=int, required=True) + args = parser.parse_args(argv) + if args.pixi_version.removeprefix("v") != "0.73.0" or args.check_status != 1: + return 1 + if re.fullmatch(r"[0-9a-f]{40}", args.original_blob) is None: + print("Source-pruning check: original blob must be a full Git SHA-1.", file=sys.stderr) + return 2 + try: + original = subprocess.run( # noqa: S603 - validated SHA, argv passed without a shell. + ["git", "cat-file", "blob", args.original_blob], # noqa: S607 + check=True, + capture_output=True, + ).stdout.decode("utf-8") + repaired = args.lockfile.read_bytes().decode("utf-8") + manifest = tomllib.loads(args.manifest_path.read_bytes().decode("utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError, subprocess.CalledProcessError) as error: + print(f"Source-pruning check could not read its inputs: {error}", file=sys.stderr) + return 2 + if not _manifest_matches(manifest): + return 1 + return 0 if is_known_samples_source_pruning(original, repaired) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/tools/tests/test_check_pixi_samples_source_pruning.py b/ci/tools/tests/test_check_pixi_samples_source_pruning.py new file mode 100644 index 00000000000..04f3887eac2 --- /dev/null +++ b/ci/tools/tests/test_check_pixi_samples_source_pruning.py @@ -0,0 +1,225 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from check_pixi_samples_source_pruning import LOCAL_PACKAGES, PLATFORMS, is_known_samples_source_pruning + +SCRIPT = Path(__file__).parent.parent / "check_pixi_samples_source_pruning.py" +MANIFEST = """[feature.samples.dependencies] +cuda-core = { path = "." } +[feature.local-deps.dependencies] +cuda-bindings = { path = "../cuda_bindings" } +cuda-pathfinder = { path = "../cuda_pathfinder" } +[feature.samples.pypi-options.dependency-overrides] +cuda-bindings = { version = "*", env-markers = "python_version < '0'" } +cuda-core = { version = "*", env-markers = "python_version < '0'" } +cuda-pathfinder = { version = "*", env-markers = "python_version < '0'" } +[environments] +samples = { features = ["cu13", "samples", "local-deps"], solve-group = "samples" } +""" + + +@pytest.fixture +def lockfiles(): + original = ["version: 7\nplatforms:\n"] + for alias, platform in PLATFORMS.items(): + original.append(f"- name: {alias}\n subdir: {platform}\n") + original.append("environments:\n cu13:\n packages: {}\n samples:\n packages:\n") + definitions = [] + references = [] + for platform_index, alias in enumerate(PLATFORMS): + original.append(f" {alias}:\n - conda: python-{alias}\n") + for package_index, (name, path) in enumerate(LOCAL_PACKAGES.items()): + identifier = f"{platform_index * 3 + package_index:08x}" + reference = f" - conda_source: {name}[{identifier}] @ {path}\n" + original.append(reference) + references.append(reference) + definitions.append(f"- conda_source: {name}[{identifier}] @ {path}\n depends:\n - python\n") + original.append("packages:\n") + original.extend(definitions) + text = "".join(original) + repaired = text + for reference in references: + repaired = repaired.replace(reference, "") + return text, repaired, references + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_exact_nine_samples_removals_are_recognized(lockfiles): + original, repaired, _ = lockfiles + assert is_known_samples_source_pruning(original, repaired) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_reverse_cycle_and_unchanged_lock_are_rejected(lockfiles): + original, repaired, _ = lockfiles + assert not is_known_samples_source_pruning(repaired, original) + assert not is_known_samples_source_pruning(original, original) + + +@pytest.mark.parametrize("field", ["version: 7", "depends:", "python-p1", "subdir: linux-64"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_any_other_repaired_byte_change_is_rejected(lockfiles, field): + original, repaired, _ = lockfiles + assert not is_known_samples_source_pruning(original, repaired.replace(field, field + "-changed", 1)) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_incomplete_removal_and_missing_original_reference_are_rejected(lockfiles): + original, repaired, references = lockfiles + assert not is_known_samples_source_pruning(original, repaired.replace(" p1:\n", " p1:\n" + references[0])) + assert not is_known_samples_source_pruning(original.replace(references[0], ""), repaired) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_duplicate_reference_is_rejected(lockfiles): + original, repaired, references = lockfiles + duplicate = original.replace(references[0], references[0] * 2) + assert not is_known_samples_source_pruning(duplicate, repaired) + + +@pytest.mark.parametrize("replacement", ["../published", "../cuda_bindings-extra"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_wrong_source_path_is_rejected(lockfiles, replacement): + original, repaired, _ = lockfiles + assert not is_known_samples_source_pruning( + original.replace("../cuda_bindings", replacement), repaired.replace("../cuda_bindings", replacement) + ) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_missing_source_record_definition_is_rejected(lockfiles): + original, repaired, _ = lockfiles + definition = "- conda_source: cuda-bindings[00000000] @ ../cuda_bindings\n" + assert not is_known_samples_source_pruning(original.replace(definition, ""), repaired.replace(definition, "")) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_changed_reference_identifier_is_rejected(lockfiles): + original, repaired, references = lockfiles + original = original.replace(references[0], references[0].replace("00000000", "ffffffff")) + assert not is_known_samples_source_pruning(original, repaired) + + +@pytest.mark.parametrize("replacement", [" p1:\n p1:\n", " unexpected:\n"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_duplicate_or_unexpected_platform_section_is_rejected(lockfiles, replacement): + original, repaired, _ = lockfiles + assert not is_known_samples_source_pruning( + original.replace(" p1:\n", replacement), repaired.replace(" p1:\n", replacement) + ) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_other_environment_removal_and_crlf_are_rejected(lockfiles): + original, repaired, references = lockfiles + original = original.replace(" packages: {}\n", " packages:\n p1:\n" + references[0]) + assert not is_known_samples_source_pruning(original, repaired) + assert not is_known_samples_source_pruning(original.replace("\n", "\r\n"), repaired.replace("\n", "\r\n")) + + +@pytest.fixture +def cli_inputs(tmp_path, lockfiles): + original, repaired, _ = lockfiles + subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) # noqa: S603, S607 + blob = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], # noqa: S607 + input=original, + text=True, + capture_output=True, + check=True, + cwd=tmp_path, + ).stdout.strip() + (tmp_path / "pixi.lock").write_text(repaired, encoding="utf-8") + (tmp_path / "pixi.toml").write_text(MANIFEST, encoding="utf-8") + return tmp_path, blob + + +def _run_cli(cli_inputs, **overrides): + cwd, blob = cli_inputs + arguments = { + "original-blob": blob, + "lockfile": "pixi.lock", + "manifest-path": "pixi.toml", + "pixi-version": "v0.73.0", + "check-status": "1", + } + arguments.update(overrides) + return subprocess.run( # noqa: S603 - controlled fixture arguments, without a shell. + [sys.executable, str(SCRIPT), *(item for key, value in arguments.items() for item in (f"--{key}", value))], + cwd=cwd, + capture_output=True, + text=True, + ) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cli_recognizes_defect(cli_inputs): + assert _run_cli(cli_inputs).returncode == 0 + + +@pytest.mark.parametrize("overrides", [{"pixi-version": "0.81.0"}, {"check-status": "0"}, {"check-status": "124"}]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cli_rejects_other_versions_and_statuses(cli_inputs, overrides): + assert _run_cli(cli_inputs, **overrides).returncode == 1 + + +@pytest.mark.parametrize( + "replacement", + [ + ("python_version < '0'", "python_version >= '0'"), + ('path = "../cuda_bindings"', 'path = "../published"'), + ('"cu13", "samples", "local-deps"', '"cu13", "samples"'), + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cli_rejects_changed_manifest_configuration(cli_inputs, replacement): + cwd, _ = cli_inputs + (cwd / "pixi.toml").write_text(MANIFEST.replace(*replacement), encoding="utf-8") + assert _run_cli(cli_inputs).returncode == 1 + + +@pytest.mark.parametrize( + "overrides", [{"original-blob": "invalid"}, {"original-blob": "0" * 40}, {"lockfile": "missing"}] +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cli_reports_operational_errors(cli_inputs, overrides): + result = _run_cli(cli_inputs, **overrides) + assert result.returncode == 2 + assert result.stderr + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cli_reports_invalid_toml(cli_inputs): + cwd, _ = cli_inputs + (cwd / "pixi.toml").write_text("[invalid", encoding="utf-8") + result = _run_cli(cli_inputs) + assert result.returncode == 2 + assert "could not read its inputs" in result.stderr + + +@pytest.mark.parametrize("manifest", ['feature = "invalid"', '[feature]\nsamples = "invalid"', "environments = []"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cli_rejects_unexpected_manifest_table_types(cli_inputs, manifest): + cwd, _ = cli_inputs + (cwd / "pixi.toml").write_text(manifest, encoding="utf-8") + result = _run_cli(cli_inputs) + assert result.returncode == 1 + assert not result.stderr + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cli_reports_invalid_utf8(cli_inputs): + cwd, _ = cli_inputs + (cwd / "pixi.lock").write_bytes(b"\xff") + result = _run_cli(cli_inputs) + assert result.returncode == 2 + assert "could not read its inputs" in result.stderr