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 90003682704..762cf097443 100644 --- a/.github/workflows/ci-pixi-lockfile-freshness-check.yml +++ b/.github/workflows/ci-pixi-lockfile-freshness-check.yml @@ -2,352 +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 - 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) - 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}" - ;; - 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 dc01a53652d..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,35 +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. -## 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/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_pixi_lockfile_freshness_workflow.py b/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py new file mode 100644 index 00000000000..d5f94837861 --- /dev/null +++ b/ci/tools/tests/test_pixi_lockfile_freshness_workflow.py @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validate the temporary lockfile maintenance pause and its check contexts.""" + +from __future__ import annotations + +import shlex +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[3] +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 _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" + 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=10, + ) + + 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.agent_authored(model="gpt-6") +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 notices[0] == notices[1] + + +@pytest.mark.agent_authored(model="gpt-6") +def test_source_builds_and_tests_still_use_frozen_pixi(): + workflow = _workflow("ci-pixi-source-test.yml") + + 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_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 {"pytest", "pyyaml"} <= dependencies