diff --git a/.github/actions/setup-pixi/action.yml b/.github/actions/setup-pixi/action.yml new file mode 100644 index 00000000000..fc43720878d --- /dev/null +++ b/.github/actions/setup-pixi/action.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +name: Setup pinned pixi +description: Install the repository's canonical pixi version without installing an environment + +outputs: + pixi-version: + description: The canonical pixi version installed by this action + value: ${{ steps.version.outputs.pixi-version }} + +runs: + using: composite + steps: + - name: Read canonical pixi version + id: version + shell: bash --noprofile --norc -euo pipefail {0} + run: | + source "${GITHUB_WORKSPACE}/ci/pixi-version.env" + if [[ ! "${PIXI_VERSION:-}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::ci/pixi-version.env must set PIXI_VERSION in vMAJOR.MINOR.PATCH form" + exit 1 + fi + echo "pixi-version=${PIXI_VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Setup pixi ${{ steps.version.outputs.pixi-version }} + # Pinned to a commit SHA; install logic lives in the action and is + # auditable/pinned (vs. a curl|bash of an unverified installer). + uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 + with: + pixi-version: ${{ steps.version.outputs.pixi-version }} + run-install: false diff --git a/.github/workflows/ci-pixi-lockfile-freshness-check.yml b/.github/workflows/ci-pixi-lockfile-freshness-check.yml index d7f9d265c9d..c4b69c83676 100644 --- a/.github/workflows/ci-pixi-lockfile-freshness-check.yml +++ b/.github/workflows/ci-pixi-lockfile-freshness-check.yml @@ -7,6 +7,9 @@ # Remediation for both: regenerate and commit the lockfile with that pixi # version, e.g. # pixi lock --manifest-path +# Keep this check advisory during its initial rollout. Before making it +# required, expose a stable aggregate check, account for the path filters, and +# record the decision in #2804 after the workflow has operational history. # See #2298. name: "CI: pixi lockfile freshness check" @@ -28,6 +31,8 @@ on: - "**/pixi.lock" - "**/pyproject.toml" - "ci/tools/list_pixi_workspaces.py" + - "ci/pixi-version.env" + - ".github/actions/setup-pixi/action.yml" - ".github/workflows/ci-pixi-lockfile-freshness-check.yml" push: # `pull_request` already covers PRs, including those from forks: this check @@ -43,9 +48,7 @@ defaults: shell: bash --noprofile --norc -xeuo pipefail {0} env: - # Keep in sync with ci-pixi-source-test.yml. Must be >=0.71.0 so the - # committed lockfile format (v7) matches what this job checks against. - PIXI_VERSION: "v0.73.0" + REFRESH_WORKFLOW_URL: "https://github.com/NVIDIA/cuda-python/actions/workflows/ci-pixi-lockfile-refresh.yml" permissions: {} @@ -92,17 +95,13 @@ jobs: persist-credentials: false - name: Setup pixi - # Pinned to a commit SHA; install logic lives in the action and is - # auditable/pinned (vs. a curl|bash of an unverified installer). - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 - with: - pixi-version: ${{ env.PIXI_VERSION }} - run-install: false + id: setup-pixi + uses: ./.github/actions/setup-pixi - name: Check lockfile is current with its manifest run: | if ! pixi lock --check --manifest-path "${{ matrix.manifest }}"; then - echo "::error::Lockfile is stale for '${{ matrix.manifest }}'. Regenerate with: pixi lock --manifest-path ${{ matrix.manifest }}" + echo "::error::Lockfile is stale for '${{ matrix.manifest }}'. Regenerate with: pixi lock --manifest-path ${{ matrix.manifest }}. For default-branch dependency drift, maintainers can run ${REFRESH_WORKFLOW_URL}" exit 1 fi @@ -112,8 +111,10 @@ jobs: # is invisible: the check prints "Updated lock file" and still passes, # while every later pixi run keeps rewriting the committed file (#2298). - name: Check lockfile is byte-for-byte canonical + env: + PIXI_VERSION: ${{ steps.setup-pixi.outputs.pixi-version }} run: | if ! git diff --exit-code -- "${{ matrix.lockfile }}"; then - echo "::error::pixi ${PIXI_VERSION} rewrote ${{ matrix.lockfile }} during the check, so the committed bytes are not what it generates. Regenerate with pixi ${PIXI_VERSION}: pixi lock --manifest-path ${{ matrix.manifest }}" + echo "::error::pixi ${PIXI_VERSION} rewrote ${{ matrix.lockfile }} during the check, so the committed bytes are not what it generates. Regenerate with pixi ${PIXI_VERSION}: pixi lock --manifest-path ${{ matrix.manifest }}. For default-branch dependency drift, maintainers can run ${REFRESH_WORKFLOW_URL}" exit 1 fi diff --git a/.github/workflows/ci-pixi-lockfile-refresh.yml b/.github/workflows/ci-pixi-lockfile-refresh.yml index 2ee774cbf1b..a69793ec3ad 100644 --- a/.github/workflows/ci-pixi-lockfile-refresh.yml +++ b/.github/workflows/ci-pixi-lockfile-refresh.yml @@ -28,10 +28,6 @@ defaults: run: shell: bash --noprofile --norc -xeuo pipefail {0} -env: - # Keep in sync with ci-pixi-source-test.yml. - PIXI_VERSION: "v0.73.0" - permissions: {} jobs: @@ -71,7 +67,7 @@ jobs: contents: write pull-requests: write concurrency: - group: pixi-lock-refresh-${{ matrix.id }} + group: pixi-lock-refresh-${{ matrix.branch_key }} cancel-in-progress: false strategy: fail-fast: false @@ -83,67 +79,67 @@ jobs: fetch-depth: 0 ref: ${{ github.event.repository.default_branch }} - # GitHub suppresses workflow-triggering events for pushes and PRs made - # with GITHUB_TOKEN, so a refresh PR opened with the default token cannot - # reach a green required-check state on its own. An App token restores - # normal event delivery; without one the PR needs a manual nudge, which - # the PR body and a run warning spell out. - - name: Mint App token for the refresh PR - id: app-token - if: ${{ vars.PIXI_LOCK_REFRESH_APP_ID != '' }} - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 - with: - app-id: ${{ vars.PIXI_LOCK_REFRESH_APP_ID }} - private-key: ${{ secrets.PIXI_LOCK_REFRESH_APP_PRIVATE_KEY }} - - name: Setup pixi - # Pinned to a commit SHA; install logic lives in the action and is - # auditable/pinned (vs. a curl|bash of an unverified installer). - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 - with: - pixi-version: ${{ env.PIXI_VERSION }} - run-install: false + id: setup-pixi + uses: ./.github/actions/setup-pixi - name: Update lockfile without installing run: pixi update --no-install --manifest-path "${{ matrix.manifest }}" + # 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. + - name: Canonicalize the updated lockfile + run: pixi lock --check --manifest-path "${{ matrix.manifest }}" + + - name: Verify canonicalization is byte-stable + env: + LOCKFILE: ${{ matrix.lockfile }} + MANIFEST: ${{ matrix.manifest }} + PIXI_VERSION: ${{ steps.setup-pixi.outputs.pixi-version }} + run: | + canonical_blob="$(git hash-object "${LOCKFILE}")" + pixi lock --check --manifest-path "${MANIFEST}" + verified_blob="$(git hash-object "${LOCKFILE}")" + if [ "${canonical_blob}" != "${verified_blob}" ]; then + echo "::error::pixi ${PIXI_VERSION} rewrote ${LOCKFILE} on a second consecutive pixi lock --check; refusing to open an unstable refresh PR." + exit 1 + fi + - name: Compose PR body id: pr-body env: WORKSPACE_ID: ${{ matrix.id }} MANIFEST: ${{ matrix.manifest }} - HAS_APP_TOKEN: ${{ steps.app-token.outputs.token != '' }} + PIXI_VERSION: ${{ steps.setup-pixi.outputs.pixi-version }} run: | body_path="${RUNNER_TEMP}/pixi-lock-refresh-body.md" { - echo "Automated lockfile refresh for \`${WORKSPACE_ID}\`" \ - "(\`pixi update --no-install --manifest-path ${MANIFEST}\`)." + echo "Automated lockfile refresh for \`${WORKSPACE_ID}\` using pixi" \ + "\`${PIXI_VERSION}\` (\`pixi update --no-install\`, followed by" \ + "byte-stable \`pixi lock --check\` canonicalization)." echo echo "This PR is workspace-scoped so other packages are not forced to review" echo "unrelated solver churn. See #2298." echo - if [ "${HAS_APP_TOKEN}" != "true" ]; then - echo "> [!IMPORTANT]" - echo "> No refresh App token is configured, so this PR was opened with" - echo "> \`GITHUB_TOKEN\` and GitHub will not deliver its \`pull_request\` events." - echo "> Required checks stay pending until a maintainer nudges the branch, by" - echo "> closing and reopening the PR or pushing an empty commit to it." - echo - fi + 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 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}" - if [ "${HAS_APP_TOKEN}" != "true" ]; then - echo "::warning::Opened with GITHUB_TOKEN; the refresh PR's checks must be triggered manually." - fi - - name: Open or update a lockfile-only PR id: cpr uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - token: ${{ steps.app-token.outputs.token || github.token }} + token: ${{ github.token }} add-paths: ${{ matrix.lockfile }} commit-message: | chore: refresh pixi.lock for ${{ matrix.id }} @@ -151,7 +147,7 @@ jobs: Scheduled pixi update --no-install for this workspace only. signoff: true sign-commits: true - branch: ci/pixi-lock-refresh/${{ matrix.id }} + branch: ci/pixi-lock-refresh/${{ matrix.branch_key }} delete-branch: true title: "chore: refresh pixi.lock (${{ matrix.id }})" labels: | @@ -161,6 +157,10 @@ jobs: 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: | { diff --git a/.github/workflows/ci-pixi-source-test.yml b/.github/workflows/ci-pixi-source-test.yml index 1dd12dd3980..f29dd307e90 100644 --- a/.github/workflows/ci-pixi-source-test.yml +++ b/.github/workflows/ci-pixi-source-test.yml @@ -47,6 +47,8 @@ on: - "cuda_bindings/tests/cython/**" - "cuda_core/tests/cython/**" - "ci/versions.yml" + - "ci/pixi-version.env" + - ".github/actions/setup-pixi/action.yml" - ".github/workflows/ci-pixi-source-test.yml" workflow_dispatch: inputs: @@ -60,13 +62,6 @@ defaults: shell: bash --noprofile --norc -xeuo pipefail {0} env: - # keep in sync with the version developers run locally. Must be >=0.71.0: - # older pixi re-ran the editable source build on every `pixi run`, recompiling - # all Cython extensions (#2138). The fix (content-addressed source-build cache, - # prefix-dev/pixi#6285 + #6123) also bumps the pixi.lock format to v7. - # Keep in sync with ci-pixi-lockfile-refresh.yml and - # ci-pixi-lockfile-freshness-check.yml. - PIXI_VERSION: "v0.73.0" # Install from the committed lockfile only. Covers nested `pixi run` calls # from root pixi.toml tasks; a stale lock must fail rather than being # refreshed in CI (#2298). Equivalent to `pixi run --locked`. @@ -91,12 +86,7 @@ jobs: fetch-depth: 0 - name: Setup pixi - # Pinned to a commit SHA; install logic lives in the action and is - # auditable/pinned (vs. a curl|bash of an unverified installer). - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 - with: - pixi-version: ${{ env.PIXI_VERSION }} - run-install: false + uses: ./.github/actions/setup-pixi - name: Source-build + import + cython-placement smoke env: @@ -152,12 +142,7 @@ jobs: fetch-depth: 0 - name: Setup pixi - # Pinned to a commit SHA; install logic lives in the action and is - # auditable/pinned (vs. a curl|bash of an unverified installer). - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 - with: - pixi-version: ${{ env.PIXI_VERSION }} - run-install: false + uses: ./.github/actions/setup-pixi - name: Build cu13, then cu12, then cu13 again in one checkout run: | @@ -217,12 +202,7 @@ jobs: continue-on-error: true - name: Setup pixi - # Pinned to a commit SHA; install logic lives in the action and is - # auditable/pinned (vs. a curl|bash of an unverified installer). - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 - with: - pixi-version: ${{ env.PIXI_VERSION }} - run-install: false + uses: ./.github/actions/setup-pixi - name: pixi run test run: pixi run -e "${{ inputs.cuda-env || 'cu13' }}" test diff --git a/.github/workflows/pr-metadata-check.yml b/.github/workflows/pr-metadata-check.yml index 076d0d47e3b..374af48abc6 100644 --- a/.github/workflows/pr-metadata-check.yml +++ b/.github/workflows/pr-metadata-check.yml @@ -26,7 +26,7 @@ jobs: if: github.repository_owner == 'NVIDIA' runs-on: ubuntu-latest steps: - - name: Check for assignee, labels, and milestone + - name: Check metadata and blocking labels env: PR_URL: ${{ github.event.pull_request.html_url }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -34,13 +34,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Keyed on the PR author, not the actor: a maintainer labelling a bot # PR re-triggers this workflow as themselves. Any bot author is - # exempt because none of them can set an assignee or milestone - # (e.g. the App identity used by the pixi lockfile refresh). - IS_BOT: ${{ github.event.pull_request.user.type == 'Bot' || github.actor == 'dependabot[bot]' || github.actor == 'pre-commit-ci[bot]' || github.actor == 'copy-pr-bot[bot]' || github.actor == 'github-actions[bot]' }} + # exempt from positive metadata because bots cannot set an assignee + # or milestone (e.g. github-actions[bot] on a generated refresh PR). + # Blocking labels are still enforced below. + IS_BOT: ${{ github.event.pull_request.user.type == 'Bot' || github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'pre-commit-ci[bot]' || github.event.pull_request.user.login == 'copy-pr-bot[bot]' || github.event.pull_request.user.login == 'github-actions[bot]' }} IS_DRAFT: ${{ github.event.pull_request.draft }} run: | - if [ "$IS_BOT" = "true" ] || [ "$IS_DRAFT" = "true" ]; then - echo "Skipping check for bot or draft PR." + if [ "$IS_DRAFT" = "true" ]; then + echo "Skipping check for draft PR." exit 0 fi @@ -48,13 +49,53 @@ jobs: # when labels/milestone are added shortly after PR creation). PR_JSON=$(gh pr view "${PR_NUMBER}" --repo "${GH_REPO}" \ --json assignees,labels,milestone \ - --jq '{assignees: .assignees, labels: .labels, milestone: (.milestone.title // empty)}') + --jq '{assignees: .assignees, labels: .labels, milestone: (.milestone.title // "")}') ASSIGNEES=$(echo "$PR_JSON" | jq '.assignees') LABELS=$(echo "$PR_JSON" | jq '.labels') MILESTONE=$(echo "$PR_JSON" | jq -r '.milestone') ERRORS="" + # Blocking labels are an explicit maintainer safety control, so they + # apply to every non-draft PR, including PRs authored by bots. + BLOCKED_LABELS=$(jq -r ' + (["blocked", "do not merge"]) as $blocking + | .[] + | .name as $n + | if ($blocking | index($n | ascii_downcase)) != null + then $n + else empty + end + ' <<<"$LABELS") + while IFS= read -r label; do + [ -n "$label" ] || continue + ERRORS="${ERRORS}- **Blocked label detected**: label \`$label\` prevents merging. Remove it when the PR is ready.\n" + done <<<"$BLOCKED_LABELS" + + # Bots cannot provide positive metadata such as an assignee or + # milestone. Once blocking labels have been checked, exempt them + # from the remaining requirements. + if [ "$IS_BOT" = "true" ]; then + if [ -n "$ERRORS" ]; then + echo "::error::This PR has a label that prevents merging. See the job summary for details." + { + echo "## PR Blocking Label Check Failed" + echo "" + printf '%b' "$ERRORS" + echo "" + echo "Please update the PR at: $PR_URL" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + { + echo "## PR Blocking Label Check Passed" + echo "" + echo "Bot-authored PR; positive metadata requirements are exempt." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + ASSIGNEE_COUNT=$(echo "$ASSIGNEES" | jq 'length') if [ "$ASSIGNEE_COUNT" -eq 0 ]; then ERRORS="${ERRORS}- **Missing assignee**: assign at least one person to this PR.\n" @@ -102,23 +143,6 @@ jobs: ERRORS="${ERRORS}- **Missing milestone**: assign a milestone to this PR.\n" fi - # Block PRs with labels that indicate they are not ready to merge. - # Match blocked label names exactly (case-insensitively); emit the - # original spelling from the payload so error text matches GitHub. - BLOCKED_LABELS=$(jq -r ' - (["blocked", "do not merge"]) as $blocking - | .[] - | .name as $n - | if ($blocking | index($n | ascii_downcase)) != null - then $n - else empty - end - ' <<<"$LABELS") - while IFS= read -r label; do - [ -n "$label" ] || continue - ERRORS="${ERRORS}- **Blocked label detected**: label \`$label\` prevents merging. Remove it when the PR is ready.\n" - done <<<"$BLOCKED_LABELS" - if [ -n "$ERRORS" ]; then echo "::error::This PR is missing required metadata. See the job summary for details." { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f78500b6cd..058080f9994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -196,11 +196,12 @@ Contributor expectations: ``` Use `--manifest-path .` for the repository-root environment. Regenerate with - the pixi version CI pins in `PIXI_VERSION` (see - `.github/workflows/ci-pixi-source-test.yml`): different pixi versions write - different canonical forms, such as the `pixi.lock` format version or the - generated platform alias names, and CI requires the committed bytes to match - what the pinned version produces. + the pixi version pinned in `ci/pixi-version.env`: different pixi versions + write different canonical forms, such as the `pixi.lock` format version or + the generated platform alias names, and CI requires the committed bytes to + match what the pinned version produces. The pin must remain at least 0.71.0, + which supplies the content-addressed source-build cache used by CI and writes + the repository's version 7 lockfiles. - If a PR does not intentionally change pixi dependencies or metadata, do not include unrelated lockfile churn. `pixi run` can refresh a stale lockfile implicitly; revert that noise unless the refresh is the point of the change. @@ -225,14 +226,22 @@ than landing inside unrelated feature work. The workflow can also be dispatched manually for one workspace or for all of them. Its dispatch input and every lockfile CI matrix resolve through `ci/tools/list_pixi_workspaces.py`, which derives the workspace list from the committed manifests, so a newly added -workspace is picked up without editing any workflow. - -Those refresh PRs need an App token to pick up CI on their own: GitHub does not -deliver workflow-triggering events for branches pushed with `GITHUB_TOKEN`. Set -the `PIXI_LOCK_REFRESH_APP_ID` variable and `PIXI_LOCK_REFRESH_APP_PRIVATE_KEY` -secret to enable that path. Until they are set, the workflow says so in the PR -body and in a run warning, and required checks stay pending until a maintainer -closes and reopens the PR or pushes to its branch. +workspace is picked up without editing any workflow. Human-readable workspace +IDs remain the dispatch and display names; the inventory generates separate, +ref-safe keys for refresh branches and workflow concurrency. + +Refresh PRs use `GITHUB_TOKEN`. After one opens, a maintainer with write access +must first select **Approve workflows to run** in the merge box, then assign +themselves to the PR. Approval starts the queued `pull_request` runs; the +human-generated `assigned` event creates the required +**PR has assignee, labels, and milestone** `pull_request_target` check and gives +the PR a clear owner. + +A future GitHub App integration could trigger both `pull_request` and +`pull_request_target` workflows automatically. +See GitHub's +[token event documentation](https://docs.github.com/en/actions/concepts/security/github_token#when-github_token-triggers-workflow-runs) +for the current behavior. ## Secret Scanning diff --git a/ci/pixi-version.env b/ci/pixi-version.env new file mode 100644 index 00000000000..4fc4d6feddf --- /dev/null +++ b/ci/pixi-version.env @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +PIXI_VERSION="v0.73.0" diff --git a/ci/tools/list_pixi_workspaces.py b/ci/tools/list_pixi_workspaces.py index 78930266ac7..5f333393c6b 100644 --- a/ci/tools/list_pixi_workspaces.py +++ b/ci/tools/list_pixi_workspaces.py @@ -7,10 +7,12 @@ matrices cannot drift from the manifests actually committed to the repository (see #2298). Each workspace is reported as an object with: - id stable name used for job names and refresh branches ("root" for - the top-level workspace, otherwise the directory path) - manifest --manifest-path argument for pixi - lockfile path of the committed lockfile + id stable, human-readable name used for selection and job names + ("root" for the top-level workspace, otherwise the directory) + branch_key deterministic, collision-resistant, ref-safe key used for Git + branches and workflow concurrency + manifest --manifest-path argument for pixi + lockfile path of the committed lockfile Every manifest must have a sibling lockfile; a missing one is an error rather than a silently skipped workspace. @@ -19,7 +21,9 @@ from __future__ import annotations import argparse +import hashlib import json +import re import subprocess import sys from pathlib import Path @@ -27,6 +31,16 @@ ROOT = Path(__file__).resolve().parents[2] ROOT_WORKSPACE_ID = "root" ALL = "all" +RESERVED_WORKSPACE_IDS = frozenset({ROOT_WORKSPACE_ID, ALL}) +_REF_KEY_UNSAFE = re.compile(r"[^a-z0-9]+") + + +def _branch_key(workspace_id: str) -> str: + """Return a readable, collision-resistant Git ref component for an ID.""" + slug = _REF_KEY_UNSAFE.sub("-", workspace_id.lower()).strip("-") or "workspace" + slug = slug[:40].rstrip("-") + digest = hashlib.sha256(workspace_id.encode("utf-8")).hexdigest()[:16] + return f"{slug}-{digest}" def discover() -> list[dict[str, str]]: @@ -45,15 +59,24 @@ def discover() -> list[dict[str, str]]: workspaces: list[dict[str, str]] = [] missing: list[str] = [] + invalid: list[str] = [] for manifest in manifests: directory = str(Path(manifest).parent.as_posix()) + workspace_id = ROOT_WORKSPACE_ID if directory == "." else directory + if directory != "." and workspace_id in RESERVED_WORKSPACE_IDS: + invalid.append( + f"{manifest} uses reserved workspace id {workspace_id!r}; " + f"{sorted(RESERVED_WORKSPACE_IDS)!r} are reserved for selection" + ) + continue lockfile = "pixi.lock" if directory == "." else f"{directory}/pixi.lock" if not (ROOT / lockfile).is_file(): missing.append(f"{manifest} has no committed {lockfile}") continue workspaces.append( { - "id": ROOT_WORKSPACE_ID if directory == "." else directory, + "id": workspace_id, + "branch_key": _branch_key(workspace_id), "manifest": directory, "lockfile": lockfile, } @@ -61,6 +84,25 @@ def discover() -> list[dict[str, str]]: if missing: raise RuntimeError("incomplete pixi workspaces:\n - " + "\n - ".join(missing)) + if invalid: + raise RuntimeError("invalid pixi workspaces:\n - " + "\n - ".join(invalid)) + + ids: dict[str, str] = {} + branch_keys: dict[str, str] = {} + for workspace in workspaces: + workspace_id = workspace["id"] + manifest = workspace["manifest"] + branch_key = workspace["branch_key"] + if previous := ids.get(workspace_id): + invalid.append(f"{manifest} and {previous} have duplicate workspace id {workspace_id!r}") + else: + ids[workspace_id] = manifest + if previous := branch_keys.get(branch_key): + invalid.append(f"{manifest} and {previous} map to duplicate refresh branch key {branch_key!r}") + else: + branch_keys[branch_key] = manifest + if invalid: + raise RuntimeError("pixi workspace inventory cannot be represented safely:\n - " + "\n - ".join(invalid)) return workspaces diff --git a/ci/tools/tests/test_list_pixi_workspaces.py b/ci/tools/tests/test_list_pixi_workspaces.py index 4e88e9d7aba..89871f9b0a4 100644 --- a/ci/tools/tests/test_list_pixi_workspaces.py +++ b/ci/tools/tests/test_list_pixi_workspaces.py @@ -30,7 +30,12 @@ def _make_repo(root: Path, manifests: list[str], *, lockfiles: list[str] | None def test_discover_this_repo(): workspaces = list_pixi_workspaces.discover() by_id = {w["id"]: w for w in workspaces} - assert by_id["root"] == {"id": "root", "manifest": ".", "lockfile": "pixi.lock"} + assert by_id["root"] == { + "id": "root", + "branch_key": list_pixi_workspaces._branch_key("root"), + "manifest": ".", + "lockfile": "pixi.lock", + } for workspace in workspaces: assert (list_pixi_workspaces.ROOT / workspace["manifest"] / "pixi.toml").is_file() assert (list_pixi_workspaces.ROOT / workspace["lockfile"]).is_file() @@ -41,13 +46,24 @@ def test_discover_reports_root_and_nested(tmp_path, monkeypatch): _make_repo(tmp_path, ["pixi.toml", "cuda_core/pixi.toml", "benchmarks/cuda_core/pixi.toml"]) monkeypatch.setattr(list_pixi_workspaces, "ROOT", tmp_path) assert list_pixi_workspaces.discover() == [ - {"id": "root", "manifest": ".", "lockfile": "pixi.lock"}, + { + "id": "root", + "branch_key": list_pixi_workspaces._branch_key("root"), + "manifest": ".", + "lockfile": "pixi.lock", + }, { "id": "benchmarks/cuda_core", + "branch_key": list_pixi_workspaces._branch_key("benchmarks/cuda_core"), "manifest": "benchmarks/cuda_core", "lockfile": "benchmarks/cuda_core/pixi.lock", }, - {"id": "cuda_core", "manifest": "cuda_core", "lockfile": "cuda_core/pixi.lock"}, + { + "id": "cuda_core", + "branch_key": list_pixi_workspaces._branch_key("cuda_core"), + "manifest": "cuda_core", + "lockfile": "cuda_core/pixi.lock", + }, ] @@ -77,6 +93,43 @@ def test_discover_rejects_repo_without_manifests(tmp_path, monkeypatch): list_pixi_workspaces.discover() +@pytest.mark.parametrize("reserved_id", ["all", "root"]) +@pytest.mark.agent_authored(model="gpt-5.6") +def test_discover_rejects_reserved_nested_workspace_id(tmp_path, monkeypatch, reserved_id): + _make_repo(tmp_path, ["pixi.toml", f"{reserved_id}/pixi.toml"]) + monkeypatch.setattr(list_pixi_workspaces, "ROOT", tmp_path) + with pytest.raises(RuntimeError, match=rf"reserved workspace id '{reserved_id}'"): + list_pixi_workspaces.discover() + + +@pytest.mark.agent_authored(model="gpt-5.6") +def test_discover_generates_distinct_ref_safe_branch_keys_for_nested_ids(tmp_path, monkeypatch): + _make_repo(tmp_path, ["foo/pixi.toml", "foo/bar/pixi.toml"]) + monkeypatch.setattr(list_pixi_workspaces, "ROOT", tmp_path) + + workspaces = list_pixi_workspaces.discover() + keys = [workspace["branch_key"] for workspace in workspaces] + + assert len(keys) == len(set(keys)) + for key in keys: + subprocess.run( # noqa: S603 - fixed command and generated branch name. + ["git", "check-ref-format", "--branch", f"ci/pixi-lock-refresh/{key}"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + + +@pytest.mark.agent_authored(model="gpt-5.6") +def test_discover_rejects_branch_key_collision(tmp_path, monkeypatch): + _make_repo(tmp_path, ["pixi.toml", "cuda_core/pixi.toml"]) + monkeypatch.setattr(list_pixi_workspaces, "ROOT", tmp_path) + monkeypatch.setattr(list_pixi_workspaces, "_branch_key", lambda _workspace_id: "collision") + + with pytest.raises(RuntimeError, match="duplicate refresh branch key 'collision'"): + list_pixi_workspaces.discover() + + @pytest.mark.agent_authored(model="claude-opus-5") def test_main_selects_one_workspace(tmp_path, monkeypatch, capsys): _make_repo(tmp_path, ["pixi.toml", "cuda_core/pixi.toml"]) @@ -84,7 +137,12 @@ def test_main_selects_one_workspace(tmp_path, monkeypatch, capsys): monkeypatch.setattr(sys, "argv", ["list_pixi_workspaces.py", "--select", "cuda_core"]) assert list_pixi_workspaces.main() == 0 assert json.loads(capsys.readouterr().out) == [ - {"id": "cuda_core", "manifest": "cuda_core", "lockfile": "cuda_core/pixi.lock"} + { + "id": "cuda_core", + "branch_key": list_pixi_workspaces._branch_key("cuda_core"), + "manifest": "cuda_core", + "lockfile": "cuda_core/pixi.lock", + } ]