Skip to content

Commit 731848e

Browse files
authored
ci: make pixi lockfile updates predictable (#2298) (#2780)
* ci: make pixi lockfile updates predictable (#2298) - Fail CI when a committed `pixi.lock` is stale (`pixi lock --check` per workspace). - Run pixi source-build CI with `PIXI_LOCKED=1` so jobs install from the lockfile instead of refreshing it. - Open monthly (and on-demand) lockfile-only PRs, one workspace at a time, via `pixi update --no-install`. - Document the contributor policy in `CONTRIBUTING.md`. Used codex(auto-balance) for changes. * Update manifest so pixi scan runs correctly * Updates wrt comments in PR. * Fix extra whitespace * Remove path enumeration * Updated from codex comments
1 parent 53d7452 commit 731848e

11 files changed

Lines changed: 1082 additions & 485 deletions
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
# Fails when a committed pixi.lock is out of date with its pixi.toml, or when
6+
# its bytes are not what the pinned pixi version generates.
7+
# Remediation for both: regenerate and commit the lockfile with that pixi
8+
# version, e.g.
9+
# pixi lock --manifest-path <path>
10+
# See #2298.
11+
12+
name: "CI: pixi lockfile freshness check"
13+
14+
concurrency:
15+
# Keyed on the event as well as the ref so a manual dispatch and a push to
16+
# the same branch do not cancel each other.
17+
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
18+
cancel-in-progress: true
19+
20+
on:
21+
pull_request:
22+
# `pyproject.toml` is a lockfile input too: the manifests consume sibling
23+
# packages (including cuda_python_test_helpers) as path dependencies, so
24+
# their metadata can stale a lock without any pixi.toml edit. Matched by
25+
# glob rather than by name so the filter cannot drift as packages move.
26+
paths: &lockfile_inputs
27+
- "**/pixi.toml"
28+
- "**/pixi.lock"
29+
- "**/pyproject.toml"
30+
- "ci/tools/list_pixi_workspaces.py"
31+
- ".github/workflows/ci-pixi-lockfile-freshness-check.yml"
32+
push:
33+
# `pull_request` already covers PRs, including those from forks: this check
34+
# needs no secrets or GPU runner. Watching copy-pr-bot's `pull-request/N`
35+
# mirror too would run the whole matrix a second time per PR.
36+
branches:
37+
- "main"
38+
paths: *lockfile_inputs
39+
workflow_dispatch: {}
40+
41+
defaults:
42+
run:
43+
shell: bash --noprofile --norc -xeuo pipefail {0}
44+
45+
env:
46+
# Keep in sync with ci-pixi-source-test.yml. Must be >=0.71.0 so the
47+
# committed lockfile format (v7) matches what this job checks against.
48+
PIXI_VERSION: "v0.73.0"
49+
50+
permissions: {}
51+
52+
jobs:
53+
# The workspace list is derived from the committed manifests so this matrix
54+
# cannot silently skip a newly added workspace (#2298).
55+
plan:
56+
name: Discover pixi workspaces
57+
if: ${{ github.repository_owner == 'nvidia' }}
58+
runs-on: ubuntu-latest
59+
timeout-minutes: 5
60+
permissions:
61+
contents: read
62+
outputs:
63+
matrix: ${{ steps.discover.outputs.matrix }}
64+
steps:
65+
- name: Checkout ${{ github.event.repository.name }}
66+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
67+
with:
68+
fetch-depth: 1
69+
persist-credentials: false
70+
71+
- name: Discover workspaces
72+
id: discover
73+
run: |
74+
workspaces="$(python3 ci/tools/list_pixi_workspaces.py)"
75+
echo "matrix=$(jq -c '{include: .}' <<<"${workspaces}")" >> "${GITHUB_OUTPUT}"
76+
77+
lockfile-fresh:
78+
name: "pixi lock --check (${{ matrix.manifest }})"
79+
needs: plan
80+
runs-on: ubuntu-latest
81+
timeout-minutes: 20
82+
permissions:
83+
contents: read
84+
strategy:
85+
fail-fast: false
86+
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
87+
steps:
88+
- name: Checkout ${{ github.event.repository.name }}
89+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
90+
with:
91+
fetch-depth: 1
92+
persist-credentials: false
93+
94+
- name: Setup pixi
95+
# Pinned to a commit SHA; install logic lives in the action and is
96+
# auditable/pinned (vs. a curl|bash of an unverified installer).
97+
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
98+
with:
99+
pixi-version: ${{ env.PIXI_VERSION }}
100+
run-install: false
101+
102+
- name: Check lockfile is current with its manifest
103+
run: |
104+
if ! pixi lock --check --manifest-path "${{ matrix.manifest }}"; then
105+
echo "::error::Lockfile is stale for '${{ matrix.manifest }}'. Regenerate with: pixi lock --manifest-path ${{ matrix.manifest }}"
106+
exit 1
107+
fi
108+
109+
# `pixi lock --check` exits 0 on a semantically current lock even when it
110+
# rewrites the file into the pinned version's canonical form (lockfile
111+
# format upgrades, platform alias renames). Without this guard that drift
112+
# is invisible: the check prints "Updated lock file" and still passes,
113+
# while every later pixi run keeps rewriting the committed file (#2298).
114+
- name: Check lockfile is byte-for-byte canonical
115+
run: |
116+
if ! git diff --exit-code -- "${{ matrix.lockfile }}"; then
117+
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 }}"
118+
exit 1
119+
fi
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
# Scheduled (and on-demand) pixi lockfile refresh. Each workspace gets its own
6+
# PR so a benchmark or docs environment cannot drag unrelated package churn
7+
# into review. See #2298.
8+
9+
name: "CI: pixi lockfile refresh"
10+
11+
on:
12+
schedule:
13+
# Monday 03:17 Eastern. GitHub handles the DST offset for the IANA zone,
14+
# and a non-zero minute avoids the start-of-hour window where GitHub
15+
# documents scheduled runs being delayed or dropped.
16+
- cron: "17 3 * * 1"
17+
timezone: "America/New_York"
18+
workflow_dispatch:
19+
inputs:
20+
package:
21+
description: >
22+
Pixi workspace id to refresh, or "all". Valid ids come from
23+
ci/tools/list_pixi_workspaces.py; an unknown id fails the run.
24+
type: string
25+
default: all
26+
27+
defaults:
28+
run:
29+
shell: bash --noprofile --norc -xeuo pipefail {0}
30+
31+
env:
32+
# Keep in sync with ci-pixi-source-test.yml.
33+
PIXI_VERSION: "v0.73.0"
34+
35+
permissions: {}
36+
37+
jobs:
38+
# Job-level `if` cannot use `matrix` (evaluated before expansion), and the
39+
# workspace list must not drift from the committed manifests, so resolve the
40+
# requested workspaces here and pass them to refresh as a dynamic matrix.
41+
plan:
42+
name: Select workspaces
43+
if: ${{ github.repository_owner == 'nvidia' }}
44+
runs-on: ubuntu-latest
45+
timeout-minutes: 5
46+
permissions:
47+
contents: read
48+
outputs:
49+
matrix: ${{ steps.select.outputs.matrix }}
50+
steps:
51+
- name: Checkout ${{ github.event.repository.name }}
52+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
53+
with:
54+
fetch-depth: 1
55+
persist-credentials: false
56+
57+
- name: Select workspaces to refresh
58+
id: select
59+
env:
60+
PACKAGE: ${{ inputs.package || 'all' }}
61+
run: |
62+
workspaces="$(python3 ci/tools/list_pixi_workspaces.py --select "${PACKAGE}")"
63+
echo "matrix=$(jq -c '{include: .}' <<<"${workspaces}")" >> "${GITHUB_OUTPUT}"
64+
65+
refresh:
66+
name: "pixi update (${{ matrix.id }})"
67+
needs: plan
68+
runs-on: ubuntu-latest
69+
timeout-minutes: 30
70+
permissions:
71+
contents: write
72+
pull-requests: write
73+
concurrency:
74+
group: pixi-lock-refresh-${{ matrix.id }}
75+
cancel-in-progress: false
76+
strategy:
77+
fail-fast: false
78+
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
79+
steps:
80+
- name: Checkout ${{ github.event.repository.name }}
81+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
82+
with:
83+
fetch-depth: 0
84+
ref: ${{ github.event.repository.default_branch }}
85+
86+
# GitHub suppresses workflow-triggering events for pushes and PRs made
87+
# with GITHUB_TOKEN, so a refresh PR opened with the default token cannot
88+
# reach a green required-check state on its own. An App token restores
89+
# normal event delivery; without one the PR needs a manual nudge, which
90+
# the PR body and a run warning spell out.
91+
- name: Mint App token for the refresh PR
92+
id: app-token
93+
if: ${{ vars.PIXI_LOCK_REFRESH_APP_ID != '' }}
94+
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
95+
with:
96+
app-id: ${{ vars.PIXI_LOCK_REFRESH_APP_ID }}
97+
private-key: ${{ secrets.PIXI_LOCK_REFRESH_APP_PRIVATE_KEY }}
98+
99+
- name: Setup pixi
100+
# Pinned to a commit SHA; install logic lives in the action and is
101+
# auditable/pinned (vs. a curl|bash of an unverified installer).
102+
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
103+
with:
104+
pixi-version: ${{ env.PIXI_VERSION }}
105+
run-install: false
106+
107+
- name: Update lockfile without installing
108+
run: pixi update --no-install --manifest-path "${{ matrix.manifest }}"
109+
110+
- name: Compose PR body
111+
id: pr-body
112+
env:
113+
WORKSPACE_ID: ${{ matrix.id }}
114+
MANIFEST: ${{ matrix.manifest }}
115+
HAS_APP_TOKEN: ${{ steps.app-token.outputs.token != '' }}
116+
run: |
117+
body_path="${RUNNER_TEMP}/pixi-lock-refresh-body.md"
118+
{
119+
echo "Automated lockfile refresh for \`${WORKSPACE_ID}\`" \
120+
"(\`pixi update --no-install --manifest-path ${MANIFEST}\`)."
121+
echo
122+
echo "This PR is workspace-scoped so other packages are not forced to review"
123+
echo "unrelated solver churn. See #2298."
124+
echo
125+
if [ "${HAS_APP_TOKEN}" != "true" ]; then
126+
echo "> [!IMPORTANT]"
127+
echo "> No refresh App token is configured, so this PR was opened with"
128+
echo "> \`GITHUB_TOKEN\` and GitHub will not deliver its \`pull_request\` events."
129+
echo "> Required checks stay pending until a maintainer nudges the branch, by"
130+
echo "> closing and reopening the PR or pushing an empty commit to it."
131+
echo
132+
fi
133+
echo "If the diff looks wrong, close this PR and leave the branch deleted; the"
134+
echo "next scheduled run opens a fresh one if an update is still needed."
135+
} > "${body_path}"
136+
echo "path=${body_path}" >> "${GITHUB_OUTPUT}"
137+
138+
if [ "${HAS_APP_TOKEN}" != "true" ]; then
139+
echo "::warning::Opened with GITHUB_TOKEN; the refresh PR's checks must be triggered manually."
140+
fi
141+
142+
- name: Open or update a lockfile-only PR
143+
id: cpr
144+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
145+
with:
146+
token: ${{ steps.app-token.outputs.token || github.token }}
147+
add-paths: ${{ matrix.lockfile }}
148+
commit-message: |
149+
chore: refresh pixi.lock for ${{ matrix.id }}
150+
151+
Scheduled pixi update --no-install for this workspace only.
152+
signoff: true
153+
sign-commits: true
154+
branch: ci/pixi-lock-refresh/${{ matrix.id }}
155+
delete-branch: true
156+
title: "chore: refresh pixi.lock (${{ matrix.id }})"
157+
labels: |
158+
CI/CD
159+
dependencies
160+
body-path: ${{ steps.pr-body.outputs.path }}
161+
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
162+
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
163+
164+
- name: Summarize
165+
run: |
166+
{
167+
echo "### pixi lock refresh (${{ matrix.id }})"
168+
echo ""
169+
echo "- Manifest: \`${{ matrix.manifest }}\`"
170+
echo "- Lockfile: \`${{ matrix.lockfile }}\`"
171+
echo "- Operation: \`${{ steps.cpr.outputs.pull-request-operation }}\`"
172+
if [ -n "${{ steps.cpr.outputs.pull-request-url }}" ]; then
173+
echo "- PR: ${{ steps.cpr.outputs.pull-request-url }}"
174+
else
175+
echo "- No lockfile diff; PR not opened."
176+
fi
177+
} >> "$GITHUB_STEP_SUMMARY"

‎.github/workflows/ci-pixi-source-test.yml‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ on:
3838
paths:
3939
- "**/pixi.toml"
4040
- "**/pixi.lock"
41+
# Path-dependency metadata (including cuda_python_test_helpers) feeds the
42+
# solve, so it can break the source build without a pixi.toml edit.
43+
- "**/pyproject.toml"
4144
- "cuda_bindings/build_hooks.py"
4245
- "cuda_core/build_hooks.py"
4346
- "cuda_bindings/cuda/bindings/**" # generated bindings sources
@@ -61,7 +64,13 @@ env:
6164
# older pixi re-ran the editable source build on every `pixi run`, recompiling
6265
# all Cython extensions (#2138). The fix (content-addressed source-build cache,
6366
# prefix-dev/pixi#6285 + #6123) also bumps the pixi.lock format to v7.
67+
# Keep in sync with ci-pixi-lockfile-refresh.yml and
68+
# ci-pixi-lockfile-freshness-check.yml.
6469
PIXI_VERSION: "v0.73.0"
70+
# Install from the committed lockfile only. Covers nested `pixi run` calls
71+
# from root pixi.toml tasks; a stale lock must fail rather than being
72+
# refreshed in CI (#2298). Equivalent to `pixi run --locked`.
73+
PIXI_LOCKED: "true"
6574

6675
jobs:
6776
# ── PR guard: CPU-only build + import + placement smoke ──
@@ -84,7 +93,7 @@ jobs:
8493
- name: Setup pixi
8594
# Pinned to a commit SHA; install logic lives in the action and is
8695
# auditable/pinned (vs. a curl|bash of an unverified installer).
87-
uses: prefix-dev/setup-pixi@d3f436a425481402e6a95a1d1fc10331c708cd9e # v0.10.2
96+
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
8897
with:
8998
pixi-version: ${{ env.PIXI_VERSION }}
9099
run-install: false
@@ -145,7 +154,7 @@ jobs:
145154
- name: Setup pixi
146155
# Pinned to a commit SHA; install logic lives in the action and is
147156
# auditable/pinned (vs. a curl|bash of an unverified installer).
148-
uses: prefix-dev/setup-pixi@d3f436a425481402e6a95a1d1fc10331c708cd9e # v0.10.2
157+
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
149158
with:
150159
pixi-version: ${{ env.PIXI_VERSION }}
151160
run-install: false
@@ -210,7 +219,7 @@ jobs:
210219
- name: Setup pixi
211220
# Pinned to a commit SHA; install logic lives in the action and is
212221
# auditable/pinned (vs. a curl|bash of an unverified installer).
213-
uses: prefix-dev/setup-pixi@d3f436a425481402e6a95a1d1fc10331c708cd9e # v0.10.2
222+
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
214223
with:
215224
pixi-version: ${{ env.PIXI_VERSION }}
216225
run-install: false

‎.github/workflows/ci-workflow-health.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ on:
1313
- "CI: Coverage"
1414
- "CI: Nightly optional-deps"
1515
- "CI: pixi run test (source build)"
16+
- "CI: pixi lockfile refresh"
17+
- "CI: pixi lockfile freshness check"
1618
- "Security Suite (Pulse + CodeQL)"
1719
- "Static Analysis: Bandit Scan"
1820
types:

‎.github/workflows/pr-metadata-check.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ jobs:
3232
PR_NUMBER: ${{ github.event.pull_request.number }}
3333
GH_REPO: ${{ github.repository }}
3434
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35-
IS_BOT: ${{ github.actor == 'dependabot[bot]' || github.actor == 'pre-commit-ci[bot]' || github.actor == 'copy-pr-bot[bot]' }}
35+
# Keyed on the PR author, not the actor: a maintainer labelling a bot
36+
# PR re-triggers this workflow as themselves. Any bot author is
37+
# exempt because none of them can set an assignee or milestone
38+
# (e.g. the App identity used by the pixi lockfile refresh).
39+
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]' }}
3640
IS_DRAFT: ${{ github.event.pull_request.draft }}
3741
run: |
3842
if [ "$IS_BOT" = "true" ] || [ "$IS_DRAFT" = "true" ]; then

‎.gitignore‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,10 @@ cython_debug/
176176
# Dont ignore
177177
!.github/actions/build/
178178
# pixi environments
179-
.pixi/*
180-
!.pixi/config.toml
179+
# Per-workspace commands such as `pixi lock --manifest-path cuda_bindings`
180+
# create a .pixi/ next to that manifest, so match at any depth.
181+
**/.pixi/*
182+
!**/.pixi/config.toml
181183

182184
# Cursor
183185
.cursorrules

0 commit comments

Comments
 (0)