From 0ba120d3bb32f276bc16dfde28c25124e168a067 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Fri, 9 Oct 2026 10:16:51 -0700 Subject: [PATCH 1/2] 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. --- .../ci-pixi-lockfile-freshness-check.yml | 14 +- ci/README.md | 9 ++ ci/tools/classify_pixi_lockfile_freshness.py | 11 +- .../test_classify_pixi_lockfile_freshness.py | 17 +++ .../test_pixi_lockfile_freshness_workflow.py | 127 ++++++++++++++++++ 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 ci/tools/tests/test_pixi_lockfile_freshness_workflow.py diff --git a/.github/workflows/ci-pixi-lockfile-freshness-check.yml b/.github/workflows/ci-pixi-lockfile-freshness-check.yml index 90003682704..5ea61aed032 100644 --- a/.github/workflows/ci-pixi-lockfile-freshness-check.yml +++ b/.github/workflows/ci-pixi-lockfile-freshness-check.yml @@ -283,6 +283,12 @@ jobs: --base-repaired "${base_repaired}" ) fi + # Matching repaired blobs do not establish base maintenance when + # this PR edits the workspace's manifest but retains its stale lock. + manifest_changed="$(git diff --name-only "${BASE_SHA}" HEAD -- "${manifest}/pixi.toml")" + if [[ -n "${manifest_changed}" ]]; then + classifier_args+=(--manifest-changed) + fi classification="$( python3 ci/tools/classify_pixi_lockfile_freshness.py "${classifier_args[@]}" )" @@ -302,8 +308,12 @@ jobs: 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}." - echo "| \`${manifest}\` | PR-induced: regenerate and commit the lockfile in this PR. |" >> "${GITHUB_STEP_SUMMARY}" + pr_reason="lockfile update required" + if [[ -n "${manifest_changed}" && "${candidate_original}" == "${base_original}" ]]; then + pr_reason="manifest changed without a lockfile update" + fi + 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}." + echo "| \`${manifest}\` | PR-induced: ${pr_reason}; regenerate and commit the lockfile in this PR. |" >> "${GITHUB_STEP_SUMMARY}" ;; base-maintenance) 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}" diff --git a/ci/README.md b/ci/README.md index dc01a53652d..10b942ecae7 100644 --- a/ci/README.md +++ b/ci/README.md @@ -25,6 +25,15 @@ 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. +## Pixi Lockfile Freshness + +The freshness check verifies every committed Pixi workspace against the pinned +Pixi version. For PRs, a stale lockfile is attributed to the PR when its own +`pixi.toml` changed but the lockfile was left unchanged, even if the base needs +the same repair. The failure asks the author to regenerate and commit the +lockfile in the PR. Changes to another workspace's manifest do not affect this +attribution. A manifest edit that leaves its lockfile fresh still passes. + ## Temporary Pixi Samples Lockfile Workaround Pixi 0.73.0 checks PyPI dependencies that the `samples` environment explicitly diff --git a/ci/tools/classify_pixi_lockfile_freshness.py b/ci/tools/classify_pixi_lockfile_freshness.py index 76addd0d171..f9ecdbd7f22 100644 --- a/ci/tools/classify_pixi_lockfile_freshness.py +++ b/ci/tools/classify_pixi_lockfile_freshness.py @@ -34,12 +34,16 @@ def __post_init__(self) -> None: raise ValueError("a fresh lockfile cannot have different original and repaired blobs") -def classify(candidate: LockfileCheck, base: LockfileCheck | None) -> Classification: +def classify(candidate: LockfileCheck, base: LockfileCheck | None, *, manifest_changed: bool = False) -> Classification: """Attribute a stale candidate lockfile using its base check, if any.""" if not candidate.stale: raise ValueError("candidate lockfile must be stale") if base is None or not base.stale: return Classification.PR_INDUCED + # A PR that edits this workspace's manifest owns its missing lockfile + # update, even when the base happens to need the same repair. + if manifest_changed and base.original_blob == candidate.original_blob: + return Classification.PR_INDUCED if base.original_blob == candidate.original_blob and base.repaired_blob == candidate.repaired_blob: return Classification.BASE_MAINTENANCE return Classification.MIXED @@ -52,6 +56,9 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--base-result", choices=("missing", "fresh", "stale"), required=True) parser.add_argument("--base-original") parser.add_argument("--base-repaired") + parser.add_argument( + "--manifest-changed", action="store_true", help="The workspace's pixi.toml differs from the PR base." + ) return parser.parse_args() @@ -77,7 +84,7 @@ def main() -> int: repaired_blob=args.base_repaired, ) - print(classify(candidate, base).value) + print(classify(candidate, base, manifest_changed=args.manifest_changed).value) return 0 diff --git a/ci/tools/tests/test_classify_pixi_lockfile_freshness.py b/ci/tools/tests/test_classify_pixi_lockfile_freshness.py index 92f6a4dc270..3dc7ddabf3e 100644 --- a/ci/tools/tests/test_classify_pixi_lockfile_freshness.py +++ b/ci/tools/tests/test_classify_pixi_lockfile_freshness.py @@ -39,6 +39,23 @@ def test_matching_stale_blobs_are_base_maintenance(): assert classify(candidate, base) is Classification.BASE_MAINTENANCE +@pytest.mark.parametrize("base_repaired", ["shared-new", "base-new"]) +@pytest.mark.agent_authored(model="gpt-6") +def test_changed_manifest_with_unchanged_stale_lockfile_is_pr_induced(base_repaired): + candidate = _check(stale=True, original="shared-old", repaired="shared-new") + base = _check(stale=True, original="shared-old", repaired=base_repaired) + + assert classify(candidate, base, manifest_changed=True) is Classification.PR_INDUCED + + +@pytest.mark.agent_authored(model="gpt-6") +def test_changed_manifest_and_changed_stale_lockfile_remain_mixed(): + candidate = _check(stale=True, original="candidate-old", repaired="shared-new") + base = _check(stale=True, original="base-old", repaired="shared-new") + + assert classify(candidate, base, manifest_changed=True) is Classification.MIXED + + @pytest.mark.parametrize( ("base_original", "base_repaired"), [ diff --git a/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py b/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py new file mode 100644 index 00000000000..09820d145b6 --- /dev/null +++ b/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run the freshness workflow against local Git history and a mock Pixi.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = ROOT / ".github/workflows/ci-pixi-lockfile-freshness-check.yml" + + +def _git(repo, *args): + return subprocess.run( # noqa: S603 - arguments are passed without a shell. + ["git", "-C", str(repo), *args], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _commit(repo): + _git(repo, "add", ".") + _git(repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "test") + return _git(repo, "rev-parse", "HEAD") + + +def _run_check(tmp_path, manifest, changed_manifest, *, stale=True, known_defect=False): + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "commit.gpgsign", "false") + tools = repo / "ci/tools" + tools.mkdir(parents=True) + for name in ("list_pixi_workspaces.py", "classify_pixi_lockfile_freshness.py"): + shutil.copyfile(ROOT / "ci/tools" / name, tools / name) + # The exact defect detector is covered separately; exercise its workflow gate. + (tools / "check_pixi_samples_source_pruning.py").write_text( + f"raise SystemExit({0 if known_defect else 1})\n", encoding="utf-8" + ) + for workspace in {".", manifest, changed_manifest}: + directory = repo / workspace + directory.mkdir(parents=True, exist_ok=True) + (directory / "pixi.toml").write_text('[workspace]\nchannels = ["conda-forge"]\n', encoding="utf-8") + content = "stale\n" if stale and workspace == manifest else "fresh\n" + (directory / "pixi.lock").write_text(content, encoding="utf-8") + base_sha = _commit(repo) + with (repo / changed_manifest / "pixi.toml").open("a", encoding="utf-8") as stream: + stream.write('\n[tasks]\nexample = "echo example"\n') + _commit(repo) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + pixi = bin_dir / "pixi" + pixi.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "from pathlib import Path\n" + "lockfile = Path(sys.argv[-1]) / 'pixi.lock'\n" + "if lockfile.read_text() == 'stale\\n':\n" + " lockfile.write_text('repaired\\n')\n" + " sys.exit(1)\n", + encoding="utf-8", + ) + pixi.chmod(0o755) + summary = tmp_path / "summary.md" + output = tmp_path / "output.txt" + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + step = next(step for step in workflow["jobs"]["lockfile-fresh"]["steps"] if step.get("id") == "check") + result = subprocess.run( # noqa: S603 - run trusted workflow code with local fixtures and no network. + ["bash", "--noprofile", "--norc", "-euo", "pipefail", "-c", step["run"]], # noqa: S607 + cwd=repo, + env={ + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "BASE_SHA": base_sha, + "EVENT_NAME": "pull_request", + "MAINTENANCE_KIND": "workflow", + "MAINTENANCE_URL": "https://example.com/refresh", + "PIXI_VERSION": "v0.73.0", + "WORKSPACE_TIMEOUT": "10s", + "RUNNER_TEMP": str(tmp_path), + "GITHUB_STEP_SUMMARY": str(summary), + "GITHUB_OUTPUT": str(output), + }, + capture_output=True, + text=True, + timeout=30, + ) + return result, summary.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("manifest", [".", "nested/workspace", "cuda_core"]) +@pytest.mark.agent_authored(model="gpt-6") +def test_manifest_edit_without_lock_update_reports_pr_remediation(tmp_path, manifest): + result, summary = _run_check(tmp_path, manifest, manifest, known_defect=manifest == "cuda_core") + + assert result.returncode == 1, result.stdout + result.stderr + assert "manifest changed without a lockfile update" in summary + assert "regenerate and commit the lockfile in this PR" in summary + assert "Base maintenance:" not in summary + assert "Do not add this refresh" not in result.stdout + assert "Known inherited Pixi samples lockfile bug" not in result.stdout + + +@pytest.mark.agent_authored(model="gpt-6") +def test_other_workspace_manifest_edit_keeps_base_maintenance_attribution(tmp_path): + result, summary = _run_check(tmp_path, "nested/workspace", ".") + + assert result.returncode == 1, result.stdout + result.stderr + assert "Base maintenance:" in summary + assert "PR-induced:" not in summary + + +@pytest.mark.agent_authored(model="gpt-6") +def test_fresh_lockfile_allows_task_only_manifest_edit(tmp_path): + result, summary = _run_check(tmp_path, "nested/workspace", "nested/workspace", stale=False) + + assert result.returncode == 0, result.stdout + result.stderr + assert "| `nested/workspace` | Fresh |" in summary From 286dc85397eeb334f1c4ddbfbadf7f03fe20d8df Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Fri, 9 Oct 2026 11:06:20 -0700 Subject: [PATCH 2/2] ci: temporarily pause Pixi lockfile maintenance --- .github/workflows/ci-nightly.yml | 2 +- .../ci-pixi-lockfile-freshness-check.yml | 340 +----------------- .../workflows/ci-pixi-lockfile-refresh.yml | 221 +----------- .github/workflows/ci-pixi-source-test.yml | 6 +- ci/README.md | 70 ++-- ci/tools/check_pixi_samples_source_pruning.py | 139 ------- ci/tools/classify_pixi_lockfile_freshness.py | 11 +- .../test_check_pixi_samples_source_pruning.py | 225 ------------ .../test_classify_pixi_lockfile_freshness.py | 17 - .../test_pixi_lockfile_freshness_workflow.py | 185 +++++----- 10 files changed, 145 insertions(+), 1071 deletions(-) delete mode 100644 ci/tools/check_pixi_samples_source_pruning.py delete mode 100644 ci/tools/tests/test_check_pixi_samples_source_pruning.py diff --git a/.github/workflows/ci-nightly.yml b/.github/workflows/ci-nightly.yml index a3179c1e155..2f4688c9f70 100644 --- a/.github/workflows/ci-nightly.yml +++ b/.github/workflows/ci-nightly.yml @@ -44,7 +44,7 @@ jobs: - name: Run CI tools unit tests run: | - python -m pip install pytest + python -m pip install pytest pyyaml==6.0.3 # Standalone CI tool tests; skip repo-root conftest.py (imports cuda.pathfinder). python -m pytest -v --noconftest ci/tools/tests diff --git a/.github/workflows/ci-pixi-lockfile-freshness-check.yml b/.github/workflows/ci-pixi-lockfile-freshness-check.yml index 5ea61aed032..762cf097443 100644 --- a/.github/workflows/ci-pixi-lockfile-freshness-check.yml +++ b/.github/workflows/ci-pixi-lockfile-freshness-check.yml @@ -2,362 +2,42 @@ # # SPDX-License-Identifier: Apache-2.0 -# Fails when a committed pixi.lock is out of date with its pixi.toml, or when -# 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. 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. - +# Keep the existing check context on every PR while enforcement is paused. +# Re-enable only after the released Pixi validation described in ci/README.md. name: "CI: pixi lockfile freshness check" concurrency: - # Keyed on the event as well as the ref so a manual dispatch and a push to - # the same branch do not cancel each other. group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true on: - pull_request: - # `pyproject.toml` is a lockfile input too: the manifests consume sibling - # packages (including cuda_python_test_helpers) as path dependencies, so - # their metadata can stale a lock without any pixi.toml edit. Matched by - # glob rather than by name so the filter cannot drift as packages move. - paths: &lockfile_inputs - - "**/pixi.toml" - - "**/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" - - ".github/workflows/ci-pixi-lockfile-freshness-check.yml" + pull_request: {} push: - # `pull_request` already covers PRs, including those from forks: this check - # needs no secrets or GPU runner. Watching copy-pr-bot's `pull-request/N` - # mirror too would run the whole check a second time per PR. branches: - "main" - paths: *lockfile_inputs workflow_dispatch: {} defaults: run: shell: bash --noprofile --norc -xeuo pipefail {0} -env: - REFRESH_WORKFLOW_URL: "https://github.com/NVIDIA/cuda-python/actions/workflows/ci-pixi-lockfile-refresh.yml" - permissions: {} jobs: lockfile-fresh: name: pixi lock --check (all workspaces) - if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest - timeout-minutes: 135 - permissions: - contents: read - pull-requests: read + timeout-minutes: 5 steps: - - name: Checkout ${{ github.event.repository.name }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Keep full ancestry for the PR base worktree, without downloading - # historical file contents (especially gh-pages). See ci/README.md. - fetch-depth: 0 - filter: blob:none - persist-credentials: false - - - name: Setup pixi - id: setup-pixi - uses: ./.github/actions/setup-pixi - - - name: Find open lockfile refresh PR - id: maintenance - if: ${{ github.event_name == 'pull_request' }} - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} + - name: Report temporary suspension run: | - lookup_status=0 - refresh_url="$( - gh pr list \ - --state open \ - --head ci/pixi-lock-refresh/all \ - --json url,isCrossRepository \ - --jq '[.[] | select(.isCrossRepository == false)][0].url // ""' - )" || lookup_status=$? - if ((lookup_status != 0)); then - echo "::warning::Could not look up the open Pixi lockfile refresh PR; using the refresh workflow link instead." - refresh_url="" - fi - if [[ -z "${refresh_url}" ]]; then - refresh_url="${REFRESH_WORKFLOW_URL}" - maintenance_kind="workflow" - else - maintenance_kind="pull-request" - fi - echo "url=${refresh_url}" >> "${GITHUB_OUTPUT}" - echo "kind=${maintenance_kind}" >> "${GITHUB_OUTPUT}" - - # `pixi lock --check` exits 0 on a semantically current lock even when it - # rewrites the file into the pinned version's canonical form (lockfile - # format upgrades, platform alias renames). Without this guard that drift - # is invisible: the check prints "Updated lock file" and still passes, - # while every later pixi run keeps rewriting the committed file (#2298). - - name: Check all lockfiles - id: check - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - EVENT_NAME: ${{ github.event_name }} - MAINTENANCE_KIND: ${{ steps.maintenance.outputs.kind || 'workflow' }} - MAINTENANCE_URL: ${{ steps.maintenance.outputs.url || env.REFRESH_WORKFLOW_URL }} - PIXI_VERSION: ${{ steps.setup-pixi.outputs.pixi-version }} - WORKSPACE_TIMEOUT: 20m - run: | - workspaces="$(python3 ci/tools/list_pixi_workspaces.py)" - workspace_rows="$( - jq -ce ' - if type != "array" or length == 0 then - error("workspace inventory must be a non-empty array") - elif any(.[]; - type != "object" - or (.manifest | type != "string") - or (.lockfile | type != "string") - or .manifest == "" - or .lockfile == "" - ) then - error("each workspace must have non-empty string manifest and lockfile fields") - else - .[] - end - ' <<<"${workspaces}" - )" - failed=0 - base_ready=0 - base_parent="" - base_worktree="" - - if [[ "${MAINTENANCE_KIND}" == "pull-request" ]]; then - maintenance_annotation="Merge the open all-workspace lockfile refresh PR at ${MAINTENANCE_URL} and then update this branch and rerun CI." - maintenance_summary="Merge the open [all-workspace lockfile refresh PR](${MAINTENANCE_URL}) and then update this branch and rerun CI." - else - maintenance_annotation="No all-workspace lockfile refresh PR is open. Open ${MAINTENANCE_URL} and select Run workflow. Run it against the default branch, merge the generated lockfile-refresh PR, update this branch, and rerun CI." - maintenance_summary="No all-workspace refresh PR is open. Open the [Pixi lockfile refresh workflow](${MAINTENANCE_URL}) and select **Run workflow**. Run it against the default branch, merge the generated lockfile-refresh PR, update this branch, and rerun CI." - fi - + echo "::warning title=Pixi lockfile maintenance suspended::Pixi lockfile freshness enforcement and automated refreshes are temporarily suspended pending a verified released Pixi fix. Freshness was not checked." { - echo "### Pixi lockfile freshness" + echo "### Pixi lockfile maintenance temporarily suspended" echo - echo "| Workspace | Result |" - echo "| --- | --- |" - } >> "${GITHUB_STEP_SUMMARY}" - - if [[ "${EVENT_NAME}" == "pull_request" ]]; then - if [[ -z "${BASE_SHA}" ]]; then - echo "::error::The pull request base SHA is unavailable, so stale lockfiles cannot be attributed. Rerun the workflow from a pull_request event; if the SHA is still missing, inspect the event payload and workflow configuration." - echo "| Workflow | Operational error: base SHA unavailable. Rerun from a pull-request event; if it remains missing, inspect the event payload and workflow configuration. |" >> "${GITHUB_STEP_SUMMARY}" - echo "remediation-printed=true" >> "${GITHUB_OUTPUT}" - exit 1 - fi - base_parent="$(mktemp -d "${RUNNER_TEMP}/pixi-lockfile-base.XXXXXX")" - base_worktree="${base_parent}/tree" - fi - - while IFS= read -r workspace; do - manifest="$(jq -r '.manifest' <<<"${workspace}")" - lockfile="$(jq -r '.lockfile' <<<"${workspace}")" - echo "::group::pixi lock --check (${manifest})" - - candidate_original="$(git hash-object "${lockfile}")" - check_status=0 - timeout --kill-after=1m "${WORKSPACE_TIMEOUT}" \ - pixi lock --check --manifest-path "${manifest}" \ - || check_status=$? - candidate_repaired="$(git hash-object "${lockfile}")" - - if ((check_status == 124 || check_status == 137)); then - echo "::error::Timed out after ${WORKSPACE_TIMEOUT} while checking '${manifest}'. Rerun the job. If it times out again, run 'pixi lock --check --manifest-path ${manifest}' with pixi ${PIXI_VERSION} and investigate solver, network, or package-index availability." - echo "| \`${manifest}\` | Operational error: candidate check timed out. Rerun the job; if it repeats, run \`pixi lock --check --manifest-path ${manifest}\` with pixi ${PIXI_VERSION} and investigate solver, network, or package-index availability. |" >> "${GITHUB_STEP_SUMMARY}" - failed=1 - echo "::endgroup::" - continue - elif ((check_status != 0)) && [[ "${candidate_original}" == "${candidate_repaired}" ]]; then - echo "::error::pixi ${PIXI_VERSION} failed while checking '${manifest}' (exit ${check_status}) without producing a repaired lockfile. Resolve the Pixi error printed above and rerun the job; do not commit a lockfile change unless a later run classifies it as PR-induced." - echo "| \`${manifest}\` | Operational error: candidate check failed with exit ${check_status}. Resolve the Pixi error above and rerun; do not commit a lockfile change unless a later run classifies it as PR-induced. |" >> "${GITHUB_STEP_SUMMARY}" - failed=1 - echo "::endgroup::" - continue - elif ((check_status == 0)) && [[ "${candidate_original}" == "${candidate_repaired}" ]]; then - echo "| \`${manifest}\` | Fresh |" >> "${GITHUB_STEP_SUMMARY}" - echo "::endgroup::" - 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 - echo "::endgroup::" - continue - fi - - if ((base_ready == 0)); then - git worktree add --detach "${base_worktree}" "${BASE_SHA}" - base_ready=1 - fi - - base_manifest="${base_worktree}/${manifest}" - base_lockfile="${base_worktree}/${lockfile}" - if [[ ! -f "${base_manifest}/pixi.toml" || ! -f "${base_lockfile}" ]]; then - base_result="missing" - base_original="" - base_repaired="" - else - base_original="$(git -C "${base_worktree}" hash-object "${lockfile}")" - base_status=0 - timeout --kill-after=1m "${WORKSPACE_TIMEOUT}" \ - pixi lock --check --manifest-path "${base_manifest}" \ - || base_status=$? - base_repaired="$(git -C "${base_worktree}" hash-object "${lockfile}")" - - if ((base_status == 124 || base_status == 137)); then - echo "::error::Timed out after ${WORKSPACE_TIMEOUT} while checking '${manifest}' at base ${BASE_SHA}. Rerun the job. If it times out again, check out ${BASE_SHA}, run 'pixi lock --check --manifest-path ${manifest}' with pixi ${PIXI_VERSION}, and resolve the operational problem before changing this PR's lockfile." - echo "| \`${manifest}\` | Operational error: base check timed out. Rerun the job; if it repeats, check out \`${BASE_SHA}\`, run \`pixi lock --check --manifest-path ${manifest}\` with pixi ${PIXI_VERSION}, and resolve the operational problem before changing this PR's lockfile. |" >> "${GITHUB_STEP_SUMMARY}" - failed=1 - echo "::endgroup::" - continue - elif ((base_status != 0)) && [[ "${base_original}" == "${base_repaired}" ]]; then - echo "::error::pixi ${PIXI_VERSION} failed while checking '${manifest}' at base ${BASE_SHA} (exit ${base_status}) without producing a repaired lockfile. Resolve the Pixi error printed above and rerun the job; do not change this PR's lockfile until base attribution succeeds." - echo "| \`${manifest}\` | Operational error: base check failed with exit ${base_status}. Resolve the Pixi error above and rerun; do not change this PR's lockfile until base attribution succeeds. |" >> "${GITHUB_STEP_SUMMARY}" - failed=1 - echo "::endgroup::" - continue - elif ((base_status == 0)) && [[ "${base_original}" == "${base_repaired}" ]]; then - base_result="fresh" - else - base_result="stale" - fi - fi - - classifier_args=( - --candidate-original "${candidate_original}" - --candidate-repaired "${candidate_repaired}" - --base-result "${base_result}" - ) - if [[ "${base_result}" != "missing" ]]; then - classifier_args+=( - --base-original "${base_original}" - --base-repaired "${base_repaired}" - ) - fi - # Matching repaired blobs do not establish base maintenance when - # this PR edits the workspace's manifest but retains its stale lock. - manifest_changed="$(git diff --name-only "${BASE_SHA}" HEAD -- "${manifest}/pixi.toml")" - if [[ -n "${manifest_changed}" ]]; then - classifier_args+=(--manifest-changed) - fi - classification="$( - 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) - pr_reason="lockfile update required" - if [[ -n "${manifest_changed}" && "${candidate_original}" == "${base_original}" ]]; then - pr_reason="manifest changed without a lockfile update" - fi - 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}." - echo "| \`${manifest}\` | PR-induced: ${pr_reason}; regenerate and commit the lockfile in this PR. |" >> "${GITHUB_STEP_SUMMARY}" - ;; - base-maintenance) - 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}" - echo "| \`${manifest}\` | Base maintenance: not introduced by this PR. Do not add this refresh to the feature PR. ${maintenance_summary} |" >> "${GITHUB_STEP_SUMMARY}" - ;; - mixed-or-ambiguous) - echo "::error title=Mixed or ambiguous Pixi lockfile change::Lockfile '${lockfile}' is stale on both the PR base and candidate, but their original or repaired blobs differ. Refresh the base first. ${maintenance_annotation} Then address any remaining PR-specific lockfile change." - echo "| \`${manifest}\` | Mixed or ambiguous: refresh the base first. ${maintenance_summary} Then address any remaining PR-specific lockfile change. |" >> "${GITHUB_STEP_SUMMARY}" - ;; - *) - echo "::error::Unknown lockfile freshness classification '${classification}' for '${manifest}'. Rerun the job; if it repeats, report a workflow bug with this classification and the candidate/base blob IDs printed above." - echo "| \`${manifest}\` | Operational error: unknown classification \`${classification}\`. Rerun the job; if it repeats, report a workflow bug with the classification and blob IDs from the log. |" >> "${GITHUB_STEP_SUMMARY}" - ;; - esac - failed=1 - - echo "::endgroup::" - done <<<"${workspace_rows}" - - if ((base_ready != 0)); then - git worktree remove --force "${base_worktree}" - fi - if [[ -n "${base_parent}" ]]; then - rmdir "${base_parent}" - fi - - if ((failed != 0)); then - echo "remediation-printed=true" >> "${GITHUB_OUTPUT}" - fi - exit "${failed}" - - # Covers checkout, setup, inventory, Git, and other unexpected failures - # that occur before the check can emit category-specific remediation. - - name: Report fallback remediation - if: ${{ failure() && steps.check.outputs.remediation-printed != 'true' }} - run: | - echo "::error title=Pixi freshness check remediation::An earlier step failed before category-specific remediation could be produced. Rerun the job once. If it fails again, resolve the first operational error in the log. For base-maintenance drift, open ${REFRESH_WORKFLOW_URL} and select Run workflow. Run it against the default branch, merge the generated lockfile-refresh PR, update this branch, and rerun CI." - { - echo "### Pixi freshness check remediation" + echo "Freshness enforcement and automated refreshes are temporarily suspended. This successful job reports the suspension; it does not verify manifest/lock consistency." echo - echo "The job failed before category-specific remediation could be produced:" + echo "Source builds and tests continue using the committed lockfiles with \`PIXI_FROZEN=true\`. Dependency changes require manual review and validation." echo - echo "1. Rerun the job once to rule out a transient checkout, network, package-index, or solver failure." - echo "2. If it fails again, resolve the first operational error in the log." - echo "3. For base-maintenance drift, open the [Pixi lockfile refresh workflow](${REFRESH_WORKFLOW_URL}) and select **Run workflow**. Run it against the default branch, merge the generated lockfile-refresh PR, update this branch, and rerun CI." - echo "4. For a PR-induced stale lockfile, regenerate the affected lockfile with the pinned Pixi version, commit it to this PR, and rerun CI." + echo "See [CI documentation](https://github.com/NVIDIA/cuda-python/blob/main/ci/README.md#temporary-pixi-lockfile-maintenance-pause) for the tradeoff and re-enable criteria." } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/ci-pixi-lockfile-refresh.yml b/.github/workflows/ci-pixi-lockfile-refresh.yml index 25c5976cc76..4bbc8b3f378 100644 --- a/.github/workflows/ci-pixi-lockfile-refresh.yml +++ b/.github/workflows/ci-pixi-lockfile-refresh.yml @@ -2,18 +2,11 @@ # # SPDX-License-Identifier: Apache-2.0 -# Scheduled (and on-demand) pixi lockfile refresh. All workspaces are updated -# in one job and, when anything changes, one PR. See #2298 and #2849. - +# Scheduled refreshes are paused. Manual dispatch reports the suspension only. +# Re-enable only after the released Pixi validation described in ci/README.md. name: "CI: pixi lockfile refresh" on: - schedule: - # Monday 03:17 Eastern. GitHub handles the DST offset for the IANA zone, - # and a non-zero minute avoids the start-of-hour window where GitHub - # documents scheduled runs being delayed or dropped. - - cron: "17 3 * * 1" - timezone: "America/New_York" workflow_dispatch: {} defaults: @@ -25,212 +18,18 @@ permissions: {} jobs: refresh: name: pixi update (all workspaces) - if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest - timeout-minutes: 195 - permissions: - contents: write - pull-requests: write - concurrency: - group: pixi-lock-refresh - cancel-in-progress: false + timeout-minutes: 5 steps: - - name: Checkout ${{ github.event.repository.name }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Full history and tags for package metadata, without historical - # file contents. See ci/README.md for the checkout policy. - fetch-depth: 0 - filter: blob:none - ref: ${{ github.event.repository.default_branch }} - - - name: Setup pixi - id: setup-pixi - uses: ./.github/actions/setup-pixi - - # Pixi 0.73.0's update and lock-read paths can serialize the same solve - # differently (#2804). Canonicalize with the same operation used by the - # freshness workflow before opening the PR. If cuda_core hits the exact - # samples-source pruning defect guarded there, retain its committed lock - # and continue refreshing the other workspaces. - - name: Update and canonicalize all lockfiles - id: update - env: - PIXI_VERSION: ${{ steps.setup-pixi.outputs.pixi-version }} - WORKSPACE_TIMEOUT: 30m + - name: Report temporary suspension run: | - workspaces="$(python3 ci/tools/list_pixi_workspaces.py)" - workspace_rows="$( - jq -ce ' - if type != "array" or length == 0 then - error("workspace inventory must be a non-empty array") - elif any(.[]; - type != "object" - or (.manifest | type != "string") - or (.lockfile | type != "string") - or .manifest == "" - or .lockfile == "" - ) then - error("each workspace must have non-empty string manifest and lockfile fields") - else - .[] - end - ' <<<"${workspaces}" - )" - - while IFS= read -r workspace; do - manifest="$(jq -r '.manifest' <<<"${workspace}")" - lockfile="$(jq -r '.lockfile' <<<"${workspace}")" - echo "::group::pixi update (${manifest})" - - workspace_status=0 - # The single-quoted script expands its variables in the child shell. - # shellcheck disable=SC2016 - timeout --kill-after=1m "${WORKSPACE_TIMEOUT}" \ - bash --noprofile --norc -xeuo pipefail -c ' - manifest="$1" - lockfile="$2" - pixi_version="$3" - - committed_blob="$(git hash-object "${lockfile}")" - pixi update --no-install --manifest-path "${manifest}" - # The detector reads this uncommitted post-update state through - # git cat-file, so explicitly persist the blob in the runner-local - # temporary object database. - updated_blob="$(git hash-object -w "${lockfile}")" - - check_status=0 - pixi lock --check --manifest-path "${manifest}" \ - || check_status=$? - if ((check_status != 0)); then - defect_status=1 - if [[ "${manifest}" == "cuda_core" ]]; then - defect_status=0 - python3 ci/tools/check_pixi_samples_source_pruning.py \ - --original-blob "${updated_blob}" \ - --lockfile "${lockfile}" \ - --manifest-path "${manifest}/pixi.toml" \ - --pixi-version "${pixi_version}" \ - --check-status "${check_status}" \ - || defect_status=$? - fi - - if ((defect_status == 0)); then - echo "::warning title=Known Pixi samples lockfile bug::Pixi ${pixi_version} removed only the nine samples local-source references after updating ${lockfile}. Keeping the committed cuda_core lockfile and continuing with the other workspaces; all other refresh failures remain strict." - git cat-file blob "${committed_blob}" > "${lockfile}" - echo "known-defect-skipped=true" >> "${GITHUB_OUTPUT}" - exit 0 - elif [[ "${manifest}" == "cuda_core" ]] && ((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." - exit "${defect_status}" - fi - - exit "${check_status}" - fi - - canonical_blob="$(git hash-object "${lockfile}")" - verify_status=0 - pixi lock --check --manifest-path "${manifest}" \ - || verify_status=$? - verified_blob="$(git hash-object "${lockfile}")" - if ((verify_status != 0)) || [[ "${canonical_blob}" != "${verified_blob}" ]]; then - echo "::error::pixi ${pixi_version} did not reach a successful byte-stable check for ${lockfile} (verification exit ${verify_status}, canonical blob ${canonical_blob}, verified blob ${verified_blob}); refusing to open an unstable refresh PR." - exit 1 - fi - ' bash "${manifest}" "${lockfile}" "${PIXI_VERSION}" \ - || workspace_status=$? - - if ((workspace_status == 124 || workspace_status == 137)); then - echo "::error::Timed out after ${WORKSPACE_TIMEOUT} while refreshing '${manifest}'." - elif ((workspace_status != 0)); then - echo "::error::Failed to refresh '${manifest}' (exit ${workspace_status})." - fi - echo "::endgroup::" - if ((workspace_status != 0)); then - exit "${workspace_status}" - fi - done <<<"${workspace_rows}" - + echo "::warning title=Pixi lockfile maintenance suspended::Pixi lockfile freshness enforcement and automated refreshes are temporarily suspended pending a verified released Pixi fix. Freshness was not checked." { - echo "lockfiles<> "${GITHUB_OUTPUT}" - - - name: Compose PR body - id: pr-body - env: - KNOWN_DEFECT_SKIPPED: ${{ steps.update.outputs.known-defect-skipped }} - PIXI_VERSION: ${{ steps.setup-pixi.outputs.pixi-version }} - run: | - body_path="${RUNNER_TEMP}/pixi-lock-refresh-body.md" - { - echo "Automated lockfile refresh using pixi" \ - "\`${PIXI_VERSION}\` (\`pixi update --no-install\`, followed by" \ - "byte-stable \`pixi lock --check\` canonicalization for each" \ - "refreshed workspace)." - if [[ "${KNOWN_DEFECT_SKIPPED}" == "true" ]]; then - echo - echo "> [!NOTE]" - echo "> \`cuda_core/pixi.lock\` was left unchanged because Pixi" \ - "\`${PIXI_VERSION}\` reproduced the exact known samples-source" \ - "pruning defect. Other workspaces were refreshed normally." - fi - echo - echo "Lockfiles covered by the refresh:" - python3 ci/tools/list_pixi_workspaces.py \ - | jq -r '.[] | "- `\(.lockfile)`"' + echo "### Pixi lockfile maintenance temporarily suspended" echo - echo "See #2298 and #2849." + echo "Freshness enforcement and automated refreshes are temporarily suspended. This successful job reports the suspension; it does not verify manifest/lock consistency." echo - echo "> [!IMPORTANT]" - echo "> This PR was opened with \`GITHUB_TOKEN\`. A maintainer with write access must:" - echo ">" - echo "> 1. Select **Approve workflows to run** in the merge box." - echo "> 2. Assign themselves to this PR. The human-generated \`assigned\` event" - echo "> creates the required **PR has assignee, labels, and milestone** check" - echo "> and gives the PR a clear owner." + echo "Source builds and tests continue using the committed lockfiles with \`PIXI_FROZEN=true\`. Dependency changes require manual review and validation." echo - echo "If the diff looks wrong, close this PR and leave the branch deleted; the" - echo "next scheduled run opens a fresh one if an update is still needed." - } > "${body_path}" - echo "path=${body_path}" >> "${GITHUB_OUTPUT}" - - - name: Open or update a lockfile-only PR - id: cpr - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - token: ${{ github.token }} - add-paths: ${{ steps.update.outputs.lockfiles }} - commit-message: "chore: refresh pixi lockfiles" - signoff: true - sign-commits: true - branch: ci/pixi-lock-refresh/all - delete-branch: true - title: "chore: refresh pixi lockfiles" - labels: | - CI/CD - dependencies - body-path: ${{ steps.pr-body.outputs.path }} - author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> - committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> - - - name: Warn about required maintainer action - if: ${{ steps.cpr.outputs.pull-request-operation == 'created' || steps.cpr.outputs.pull-request-operation == 'updated' }} - run: echo "::warning::The refresh PR requires a maintainer to approve its workflow runs and assign themselves to create the required metadata check." - - - name: Summarize - run: | - { - echo "### pixi lock refresh (all workspaces)" - echo "" - echo "- Operation: \`${{ steps.cpr.outputs.pull-request-operation }}\`" - if [ -n "${{ steps.cpr.outputs.pull-request-url }}" ]; then - echo "- PR: ${{ steps.cpr.outputs.pull-request-url }}" - else - echo "- No lockfile diff; PR not opened." - fi - if [[ "${{ steps.update.outputs.known-defect-skipped }}" == "true" ]]; then - echo "- \`cuda_core/pixi.lock\` retained its committed state because the exact known Pixi samples-source pruning defect was detected." - fi - } >> "$GITHUB_STEP_SUMMARY" + echo "See [CI documentation](https://github.com/NVIDIA/cuda-python/blob/main/ci/README.md#temporary-pixi-lockfile-maintenance-pause) for the tradeoff and re-enable criteria." + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/ci-pixi-source-test.yml b/.github/workflows/ci-pixi-source-test.yml index 5407c4e9e98..7b53d004fa6 100644 --- a/.github/workflows/ci-pixi-source-test.yml +++ b/.github/workflows/ci-pixi-source-test.yml @@ -65,9 +65,9 @@ env: # 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. + # including nested `pixi run` calls. Freshness enforcement and automated + # refreshes are temporarily suspended; dependency changes need manual review. + # Restore PIXI_LOCKED after the released-fix validation in ci/README.md. PIXI_FROZEN: "true" jobs: diff --git a/ci/README.md b/ci/README.md index 10b942ecae7..be94dfb035d 100644 --- a/ci/README.md +++ b/ci/README.md @@ -10,8 +10,7 @@ For those jobs, use `fetch-depth: 0` with `filter: blob:none`. The checkout action fetches complete ancestry for all branches and tags, while the blob filter skips historical file contents. This avoids downloading the large historical `gh-pages` contents discussed in issue #2197. Git retrieves file -contents as needed when checking out the requested commit or creating the -freshness check's base-commit worktree. +contents as needed when checking out the requested commit. CI change detection uses this full-history checkout for PR mirrors, so the actual PR base branch is available when computing its merge base. Non-PR runs @@ -25,44 +24,35 @@ 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. -## Pixi Lockfile Freshness - -The freshness check verifies every committed Pixi workspace against the pinned -Pixi version. For PRs, a stale lockfile is attributed to the PR when its own -`pixi.toml` changed but the lockfile was left unchanged, even if the base needs -the same repair. The failure asks the author to regenerate and commit the -lockfile in the PR. Changes to another workspace's manifest do not affect this -attribution. A manifest edit that leaves its lockfile fresh still passes. - -## 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. - -The refresh workflow uses the same exact detector after updating `cuda_core`. -On a match, it restores the pre-update committed lockfile and continues -refreshing the other workspaces. This postpones `cuda_core` dependency updates -without allowing an unstable lockfile into the generated refresh PR; every -unrecognized refresh failure remains fatal. - -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. +## Temporary Pixi Lockfile Maintenance Pause + +Pixi lockfile freshness enforcement and automated refreshes are temporarily +suspended because of [Pixi #7215](https://github.com/prefix-dev/pixi/issues/7215) +and [#7000](https://github.com/prefix-dev/pixi/issues/7000). Repeated lockfile +operations can change the committed solution without converging, so a general +refresh is not a reliable repair. + +The `CI: pixi lockfile freshness check` workflow still runs on every PR with its +existing `pixi lock --check (all workspaces)` check name. It succeeds with a +warning annotation and job summary explicitly reporting the suspension; +success does not mean freshness passed. The refresh workflow has no schedule. +Manual dispatch emits the same notice without solving dependencies, rewriting +lockfiles, pushing a branch, or creating a PR. + +Manifest/lock consistency is not automatically enforced during this pause. +Necessary dependency changes require manual review and validation of the +affected manifests and lockfiles. Real source builds and tests continue using +`PIXI_FROZEN=true`, including nested `pixi run` calls, to build the checkout's +source packages against the committed dependency solution. The pinned Pixi +version and committed lockfiles are unchanged. + +Re-enable maintenance only after a released Pixi passes both upstream issue +reproducers and repeated byte-stable update/lock/check cycles across all six +workspaces: the repository root, `cuda_pathfinder`, `cuda_bindings`, `cuda_core`, +`benchmarks/cuda_bindings`, and `benchmarks/cuda_core`. Verify both `pixi update +--no-install` and `pixi lock`, followed by repeated `pixi lock --check` commands, +converge to unchanged lockfile bytes. Then restore strict freshness checks, +scheduled refreshes, and `PIXI_LOCKED=true` in source-build CI. ## Repository Customizations diff --git a/ci/tools/check_pixi_samples_source_pruning.py b/ci/tools/check_pixi_samples_source_pruning.py deleted file mode 100644 index 1ec054f0171..00000000000 --- a/ci/tools/check_pixi_samples_source_pruning.py +++ /dev/null @@ -1,139 +0,0 @@ -# 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/classify_pixi_lockfile_freshness.py b/ci/tools/classify_pixi_lockfile_freshness.py index f9ecdbd7f22..76addd0d171 100644 --- a/ci/tools/classify_pixi_lockfile_freshness.py +++ b/ci/tools/classify_pixi_lockfile_freshness.py @@ -34,16 +34,12 @@ def __post_init__(self) -> None: raise ValueError("a fresh lockfile cannot have different original and repaired blobs") -def classify(candidate: LockfileCheck, base: LockfileCheck | None, *, manifest_changed: bool = False) -> Classification: +def classify(candidate: LockfileCheck, base: LockfileCheck | None) -> Classification: """Attribute a stale candidate lockfile using its base check, if any.""" if not candidate.stale: raise ValueError("candidate lockfile must be stale") if base is None or not base.stale: return Classification.PR_INDUCED - # A PR that edits this workspace's manifest owns its missing lockfile - # update, even when the base happens to need the same repair. - if manifest_changed and base.original_blob == candidate.original_blob: - return Classification.PR_INDUCED if base.original_blob == candidate.original_blob and base.repaired_blob == candidate.repaired_blob: return Classification.BASE_MAINTENANCE return Classification.MIXED @@ -56,9 +52,6 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--base-result", choices=("missing", "fresh", "stale"), required=True) parser.add_argument("--base-original") parser.add_argument("--base-repaired") - parser.add_argument( - "--manifest-changed", action="store_true", help="The workspace's pixi.toml differs from the PR base." - ) return parser.parse_args() @@ -84,7 +77,7 @@ def main() -> int: repaired_blob=args.base_repaired, ) - print(classify(candidate, base, manifest_changed=args.manifest_changed).value) + print(classify(candidate, base).value) return 0 diff --git a/ci/tools/tests/test_check_pixi_samples_source_pruning.py b/ci/tools/tests/test_check_pixi_samples_source_pruning.py deleted file mode 100644 index 04f3887eac2..00000000000 --- a/ci/tools/tests/test_check_pixi_samples_source_pruning.py +++ /dev/null @@ -1,225 +0,0 @@ -# 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 diff --git a/ci/tools/tests/test_classify_pixi_lockfile_freshness.py b/ci/tools/tests/test_classify_pixi_lockfile_freshness.py index 3dc7ddabf3e..92f6a4dc270 100644 --- a/ci/tools/tests/test_classify_pixi_lockfile_freshness.py +++ b/ci/tools/tests/test_classify_pixi_lockfile_freshness.py @@ -39,23 +39,6 @@ def test_matching_stale_blobs_are_base_maintenance(): assert classify(candidate, base) is Classification.BASE_MAINTENANCE -@pytest.mark.parametrize("base_repaired", ["shared-new", "base-new"]) -@pytest.mark.agent_authored(model="gpt-6") -def test_changed_manifest_with_unchanged_stale_lockfile_is_pr_induced(base_repaired): - candidate = _check(stale=True, original="shared-old", repaired="shared-new") - base = _check(stale=True, original="shared-old", repaired=base_repaired) - - assert classify(candidate, base, manifest_changed=True) is Classification.PR_INDUCED - - -@pytest.mark.agent_authored(model="gpt-6") -def test_changed_manifest_and_changed_stale_lockfile_remain_mixed(): - candidate = _check(stale=True, original="candidate-old", repaired="shared-new") - base = _check(stale=True, original="base-old", repaired="shared-new") - - assert classify(candidate, base, manifest_changed=True) is Classification.MIXED - - @pytest.mark.parametrize( ("base_original", "base_repaired"), [ diff --git a/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py b/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py index 09820d145b6..d5f94837861 100644 --- a/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py +++ b/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py @@ -1,12 +1,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Run the freshness workflow against local Git history and a mock Pixi.""" +"""Validate the temporary lockfile maintenance pause and its check contexts.""" from __future__ import annotations -import os -import shutil +import shlex import subprocess from pathlib import Path @@ -14,114 +13,108 @@ import yaml ROOT = Path(__file__).resolve().parents[3] -WORKFLOW = ROOT / ".github/workflows/ci-pixi-lockfile-freshness-check.yml" +WORKFLOWS = ROOT / ".github/workflows" +PAUSED_WORKFLOWS = ( + ("ci-pixi-lockfile-freshness-check.yml", "CI: pixi lockfile freshness check", "pixi lock --check (all workspaces)"), + ("ci-pixi-lockfile-refresh.yml", "CI: pixi lockfile refresh", "pixi update (all workspaces)"), +) -def _git(repo, *args): - return subprocess.run( # noqa: S603 - arguments are passed without a shell. - ["git", "-C", str(repo), *args], # noqa: S607 - check=True, - capture_output=True, - text=True, - ).stdout.strip() - - -def _commit(repo): - _git(repo, "add", ".") - _git(repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "test") - return _git(repo, "rev-parse", "HEAD") - - -def _run_check(tmp_path, manifest, changed_manifest, *, stale=True, known_defect=False): - repo = tmp_path / "repo" - repo.mkdir() - _git(repo, "init", "-q") - _git(repo, "config", "commit.gpgsign", "false") - tools = repo / "ci/tools" - tools.mkdir(parents=True) - for name in ("list_pixi_workspaces.py", "classify_pixi_lockfile_freshness.py"): - shutil.copyfile(ROOT / "ci/tools" / name, tools / name) - # The exact defect detector is covered separately; exercise its workflow gate. - (tools / "check_pixi_samples_source_pruning.py").write_text( - f"raise SystemExit({0 if known_defect else 1})\n", encoding="utf-8" - ) - for workspace in {".", manifest, changed_manifest}: - directory = repo / workspace - directory.mkdir(parents=True, exist_ok=True) - (directory / "pixi.toml").write_text('[workspace]\nchannels = ["conda-forge"]\n', encoding="utf-8") - content = "stale\n" if stale and workspace == manifest else "fresh\n" - (directory / "pixi.lock").write_text(content, encoding="utf-8") - base_sha = _commit(repo) - with (repo / changed_manifest / "pixi.toml").open("a", encoding="utf-8") as stream: - stream.write('\n[tasks]\nexample = "echo example"\n') - _commit(repo) - - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - pixi = bin_dir / "pixi" - pixi.write_text( - "#!/usr/bin/env python3\n" - "import sys\n" - "from pathlib import Path\n" - "lockfile = Path(sys.argv[-1]) / 'pixi.lock'\n" - "if lockfile.read_text() == 'stale\\n':\n" - " lockfile.write_text('repaired\\n')\n" - " sys.exit(1)\n", - encoding="utf-8", - ) - pixi.chmod(0o755) +def _workflow(filename): + workflow = yaml.safe_load((WORKFLOWS / filename).read_text(encoding="utf-8")) + # PyYAML interprets Actions' unquoted `on` key as a YAML 1.1 boolean. + if True in workflow: + workflow["on"] = workflow.pop(True) + return workflow + + +@pytest.mark.agent_authored(model="gpt-6") +def test_freshness_notice_runs_on_every_pr_without_path_filters(): + workflow = _workflow("ci-pixi-lockfile-freshness-check.yml") + + assert workflow["on"]["pull_request"] == {} + assert "pull_request_target" not in workflow["on"] + assert workflow["on"]["workflow_dispatch"] == {} + + +@pytest.mark.agent_authored(model="gpt-6") +def test_refresh_is_manual_only_during_pause(): + workflow = _workflow("ci-pixi-lockfile-refresh.yml") + + assert workflow["on"] == {"workflow_dispatch": {}} + + +@pytest.mark.parametrize(("filename", "workflow_name", "job_name"), PAUSED_WORKFLOWS) +@pytest.mark.agent_authored(model="gpt-6") +def test_paused_workflows_preserve_names_and_run_only_the_notice(filename, workflow_name, job_name): + workflow = _workflow(filename) + + assert workflow["name"] == workflow_name + assert workflow["permissions"] == {} + assert len(workflow["jobs"]) == 1 + job = next(iter(workflow["jobs"].values())) + assert job["name"] == job_name + assert not {"if", "needs", "uses", "strategy", "continue-on-error", "permissions"}.intersection(job) + assert len(job["steps"]) == 1 + assert set(job["steps"][0]) == {"name", "run"} + + +@pytest.mark.parametrize(("filename", "workflow_name", "job_name"), PAUSED_WORKFLOWS) +@pytest.mark.agent_authored(model="gpt-6") +def test_suspension_notice_succeeds_without_external_tools_or_repository_writes( + tmp_path, filename, workflow_name, job_name +): + workflow = _workflow(filename) + job = next(iter(workflow["jobs"].values())) summary = tmp_path / "summary.md" - output = tmp_path / "output.txt" - workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) - step = next(step for step in workflow["jobs"]["lockfile-fresh"]["steps"] if step.get("id") == "check") - result = subprocess.run( # noqa: S603 - run trusted workflow code with local fixtures and no network. - ["bash", "--noprofile", "--norc", "-euo", "pipefail", "-c", step["run"]], # noqa: S607 - cwd=repo, - env={ - **os.environ, - "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", - "BASE_SHA": base_sha, - "EVENT_NAME": "pull_request", - "MAINTENANCE_KIND": "workflow", - "MAINTENANCE_URL": "https://example.com/refresh", - "PIXI_VERSION": "v0.73.0", - "WORKSPACE_TIMEOUT": "10s", - "RUNNER_TEMP": str(tmp_path), - "GITHUB_STEP_SUMMARY": str(summary), - "GITHUB_OUTPUT": str(output), - }, + result = subprocess.run( # noqa: S603 - execute the trusted notice with no external tools in PATH. + ["/bin/bash", "--noprofile", "--norc", "-euo", "pipefail", "-c", job["steps"][0]["run"]], + cwd=tmp_path, + env={"PATH": str(tmp_path / "no-tools"), "GITHUB_STEP_SUMMARY": str(summary)}, capture_output=True, text=True, - timeout=30, + timeout=10, ) - return result, summary.read_text(encoding="utf-8") + + assert result.returncode == 0, result.stdout + result.stderr + assert "::warning title=Pixi lockfile maintenance suspended::" in result.stdout + assert "temporarily suspended" in result.stdout + assert "Freshness was not checked." in result.stdout + text = summary.read_text(encoding="utf-8") + assert "Freshness enforcement and automated refreshes are temporarily suspended." in text + assert "does not verify manifest/lock consistency" in text + assert "manual review and validation" in text + assert "PIXI_FROZEN=true" in text + assert set(tmp_path.iterdir()) == {summary} -@pytest.mark.parametrize("manifest", [".", "nested/workspace", "cuda_core"]) @pytest.mark.agent_authored(model="gpt-6") -def test_manifest_edit_without_lock_update_reports_pr_remediation(tmp_path, manifest): - result, summary = _run_check(tmp_path, manifest, manifest, known_defect=manifest == "cuda_core") +def test_both_workflows_emit_the_same_suspension_notice(): + notices = [ + next(iter(_workflow(filename)["jobs"].values()))["steps"][0]["run"] for filename, _, _ in PAUSED_WORKFLOWS + ] - assert result.returncode == 1, result.stdout + result.stderr - assert "manifest changed without a lockfile update" in summary - assert "regenerate and commit the lockfile in this PR" in summary - assert "Base maintenance:" not in summary - assert "Do not add this refresh" not in result.stdout - assert "Known inherited Pixi samples lockfile bug" not in result.stdout + assert notices[0] == notices[1] @pytest.mark.agent_authored(model="gpt-6") -def test_other_workspace_manifest_edit_keeps_base_maintenance_attribution(tmp_path): - result, summary = _run_check(tmp_path, "nested/workspace", ".") +def test_source_builds_and_tests_still_use_frozen_pixi(): + workflow = _workflow("ci-pixi-source-test.yml") - assert result.returncode == 1, result.stdout + result.stderr - assert "Base maintenance:" in summary - assert "PR-induced:" not in summary + assert workflow["env"]["PIXI_FROZEN"] == "true" + assert "PIXI_LOCKED" not in workflow["env"] + assert set(workflow["jobs"]) == {"build-smoke", "build-identity-roundtrip", "full-test"} + assert {"pull_request", "schedule", "workflow_dispatch"} <= set(workflow["on"]) + for job in workflow["jobs"].values(): + assert any(step.get("uses") == "./.github/actions/setup-pixi" for step in job["steps"]) + assert any("pixi run" in step.get("run", "") for step in job["steps"]) @pytest.mark.agent_authored(model="gpt-6") -def test_fresh_lockfile_allows_task_only_manifest_edit(tmp_path): - result, summary = _run_check(tmp_path, "nested/workspace", "nested/workspace", stale=False) +def test_nightly_ci_declares_workflow_test_dependencies(): + workflow = _workflow("ci-nightly.yml") + step = next(step for step in workflow["jobs"]["test-ci-tools-for-release"]["steps"] if "run" in step) + install = next(line for line in step["run"].splitlines() if "pip install" in line) + dependencies = {argument.lower().split("==")[0] for argument in shlex.split(install)} - assert result.returncode == 0, result.stdout + result.stderr - assert "| `nested/workspace` | Fresh |" in summary + assert {"pytest", "pyyaml"} <= dependencies