Skip to content

chore(ci): pin and enforce one uv/CPython version across Python CI, gate committed lockfiles, and harden fork-artifact handling in publish-python-preview - #2338

Merged
mme merged 9 commits into
mainfrom
markus/pni-187-pin-one-uv-version-across-ag-ui-ci
Aug 6, 2026
Merged

chore(ci): pin and enforce one uv/CPython version across Python CI, gate committed lockfiles, and harden fork-artifact handling in publish-python-preview#2338
mme merged 9 commits into
mainfrom
markus/pni-187-pin-one-uv-version-across-ag-ui-ci

Conversation

@mme

@mme mme commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes PNI-187.

Python CI did not pin the tool that builds its environments, and nothing verified that a committed lockfile still matched its manifest for packages without a test lane.

Contains a security fix that is not about toolchain pinning. publish-python-preview.yml was passing fork-produced artifact contents into a job holding pull-requests: write via echo "k=$(cat f)", which exits 0 when cat fails and lets a newline in any value set arbitrary extra step outputs. See Fork-artifact hardening below. Flagged in review as hard to find under a pinning changelog entry — hence this callout.

What changed

One uv, one CPython, everywhere. All 18 astral-sh/setup-uv invocations resolved to a floating >=0.8.0 or named no version at all, so any job could silently get a uv that no green run had used. Each now takes an exact version from .github/python-toolchain.env, which also records the run those values came from — run 30958253208 (unit-python-sdk.yml, main @ bfc22e4e) installed uv 0.12.1 in every Python job and built every venv against CPython 3.12.3.

That pin is enforced, not just recorded. scripts/release/verify-python-toolchain-pins.sh runs as the python-toolchain-pins job and fails the build when a workflow's literal disagrees with the record, or when a setup-uv invocation gets its version any other way. Two passes, because the second catches what the first structurally cannot:

  1. every UV_VERSION / PYTHON_VERSION literal under .github/workflows/ equals the record — reported as file:line, and quote-tolerant so an unquoted pin is not misread as absent
  2. every uses: astral-sh/setup-uv@ has a matching version: ${{ env.UV_VERSION }} — a newly added invocation that names no version declares nothing for pass 1 to compare, which is exactly the state all 18 were in before this change

Both refuse to pass vacuously: renamed or reindented pins fail loudly rather than inspecting nothing.

Venv cache keys carry both versions. Previously a cached environment built by one toolchain could be restored into a job expecting another, which hides drift rather than surfacing it.

Lockfile drift fails the build. uv sync --locked already covers the nine packages with a test lane. A new lockfiles job runs uv lock --check over every first-party lock, which is what closes the gap: agent-spec and claude-agent-sdk ship committed lockfiles with no lane at all, and that is how the drift #2313 had to repair went unnoticed across several releases. A second step asserts no first-party package is missing a lockfile, since a package with none is invisible to a find-based check and passes silently.

No job may rewrite a committed lockfile. Each Python job that installs dependencies now ends with .github/actions/assert-lockfiles-unchanged. It measures the outcome — git status over every tracked lockfile — rather than trying to infer which commands a job ran.

Fork-artifact hardening (publish-python-preview.yml)

Called out separately because it is a security change, not housekeeping, and would not be findable under this PR's title otherwise.

That workflow runs on workflow_run, so on a fork PR the three metadata files it reads are produced by the fork's run — untrusted input reaching a job that holds pull-requests: write. The previous echo "k=$(cat f)" form had two defects: it exits 0 when cat fails, so a missing file silently yielded an empty value; and a value containing a newline could set arbitrary additional step outputs. Now set -euo pipefail, shape validation on all three values, and a single grouped redirect. The version regex was checked against what build-python-preview.yml actually emits (0.0.0.dev${git log -1 --format=%ct}).

It stayed in this PR rather than being split because adding the file to the actionlint scope is what surfaced it (SC2129 at error level), so splitting would mean either landing a red lint job or reverting the scope addition. Splitting was considered and rejected on that basis — see the thread.

Design notes for review

prepare-release.yml is where the pin matters most. Since #2314 that workflow runs uv, and its output lands in a committed lockfile — so an exact version there is load-bearing in a way it is not elsewhere.

examples/ lockfiles are deliberately out of scope. prep-dojo-everything.js syncs those non-frozen on purpose (it is shared with local dev, where relocking is wanted), so the checks above exclude them, and !examples/** keeps an examples-only lock edit from running the nine lanes that exclude examples anyway. Documented at the lockfiles job and in CONTRIBUTING.

The composite action's shell lives in a .sh file. Not cosmetic: actionlint 1.7.x has no composite-action mode, so pointed at an action.yml it parses it as a workflow and emits four bogus syntax-check errors. Shell embedded in a composite action is therefore linted by nothing. Extracting it makes the shellcheck job cover it — which now globs .github/actions too.

Third pin caveat worth knowing: naming python-version on setup-uv also exports UV_PYTHON for the whole job, so every later uv call in it is constrained to that interpreter. Every requires-python in the repo admits 3.12 today; a future example pinned to >=3.13 would fail inside dojo-e2e rather than in the package that declared it. Recorded in the pins file.

One waiver, deliberately left open. sdks/python/a2ui_toolkit ships as ag-ui-a2ui-toolkit and has no lockfile, so it is the one published package the new gate cannot cover. It declares dependencies = [], so nothing is unverified today and the committed lock would be near-empty; the exposure is that the first dependency anyone adds arrives invisible to the gate. Closing it changes that package's release behaviour and empties known_missing, so it is PNI-279 rather than a rider here.

Verification

Run locally against 325907e5:

check result
setup-uv invocations pinned 18 / 18
pin declarations agreeing with the record 17 / 17
verify-python-toolchain-pins.sh goes red on drift 4 / 4 cases (drifted literal, unquoted-but-wrong pin, unpinned setup-uv, renamed pins)
venv cache keys carrying both versions 11 / 11
hardcoded python-version stragglers 0
uv lock --check across first-party locks 10 / 10 pass
actionlint over the linted scope clean
shellcheck (scripts/release + .github/actions, 12 files) clean, under bash 3.2
actionlint_flags parses to 10 existing paths, no stray args confirmed
release script tests 92 / 92 pass
zizmor vs. merge-base baseline 0 new findings across all 9 rules

Python runtime support ranges are unchanged — this is about the tooling that builds and tests, not which Python versions the packages support.

For whoever merges

Expect a one-time cold cache. Every venv cache key and dojo-e2e's restore-keys change at once, so the first run after merge misses across ~10 caches. That is expected, not a regression.

Commits

Split by area of concern, each independently reviewable:

  1. enforce the pinned toolchain instead of documenting it — the check, the pins file, the lint scope, CONTRIBUTING
  2. move the lockfile assertion's shell into a linted script
  3. tighten the lockfile gate's conditions, scope and waiver
  4. connect the Node and Python toolchain records

Both values are the ones a green run actually resolved, and that run is identified.
The file is documentation, not a mechanism: GitHub cannot read it into a workflow's
env: block, so each Python workflow repeats the values it needs and keeping them in
step is a convention and a review item. Records three caveats the pin does not
close, including that naming python-version also exports UV_PYTHON job-wide.
@mme
mme requested a review from a team as a code owner August 6, 2026 12:39
Comment thread .github/workflows/lint-release-workflows.yml Fixed
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1786027393 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1786027393' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1786027393' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1786027393' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1786027393' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1786027393' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1786027393' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1786027393

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 5a21107

@pkg-pr-new

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2338

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2338

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2338

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2338

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2338

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2338

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2338

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2338

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2338

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2338

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2338

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2338

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2338

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2338

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2338

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2338

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2338

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2338

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2338

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2338

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2338

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2338

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2338

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2338

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2338

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2338

commit: 325907e

mme added 3 commits August 6, 2026 15:02
All 18 astral-sh/setup-uv invocations resolved to a floating '>=0.8.0' or named no
version at all, so any job could silently get a uv no green run had used. Each now
takes the exact pin from .github/python-toolchain.env.

Venv cache keys gain the same two versions, so an environment built by one toolchain
is never restored into a job expecting another — the failure mode that hides drift
rather than surfacing it.

The pin matters most in prepare-release.yml: since #2314 that is the uv whose output
lands in a committed lockfile. Also declares the depot self-hosted runner label so
the widened actionlint scope resolves it.
Adds the 'uv lock --check' gate #2314 named as its companion. It runs over every
first-party lock, which widens coverage past the nine packages that have a test
lane: agent-spec and claude-agent-sdk ship committed locks with no lane at all,
which is how the drift #2313 repaired went unnoticed for several releases. It also
asserts no first-party package is missing a lock, since a package with none is
invisible to a find-based check and passes silently.

Each Python job that installs dependencies now ends with an assertion that it left
no committed lockfile modified. That is measured by outcome — git status over every
tracked lockfile — so nothing has to infer which commands a job ran. It runs with
if: always(), so a job that rewrote a lock and then failed still reports it.
Separates the two halves by how they are enforced: matching the pin is a convention
reviewers check, while the lockfile rules fail the build. States the examples/
exemption that reconciles the plain uv sync used there with the frozen Python jobs.
@mme
mme force-pushed the markus/pni-187-pin-one-uv-version-across-ag-ui-ci branch from fd7ee69 to b66a49a Compare August 6, 2026 13:04
@mme
mme enabled auto-merge August 6, 2026 13:09

@contextablemark contextablemark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Careful, well-documented change, and the core of it is clearly right — pinning the tool that builds the environments, and gating the two packages that had committed locks but no test lane, is exactly the gap #2313 exposed. I verified the mechanics locally against b66a49a rather than taking the table on faith:

claim checked
18 setup-uv invocations, all pinned confirmed (19 grep hits; one is prose in zizmor.yml:14)
17 pin declarations agreeing confirmed — only "0.12.1" and "3.12" appear across all 8 workflows
no hardcoded python-version stragglers confirmed, none left in .github/workflows
lockfiles discovery steps ran both — 10/10 locks found, sdks/python/a2ui_toolkit the sole miss
assert-lockfiles-unchanged pathspec safety .gitignore:28 covers every .venv these jobs create
release path won't spring a surprise lock prepare-release.ts:358 early-returns for lock-less packages

Requesting changes on one item; the rest are inline and mostly cheap.

The blocking one: the pin's own invariant is the only thing here left unenforced, and the stated reason doesn't survive contact. The removed ~1,400-line checker is cited as evidence that checking this is expensive, but comparing 17 literals against two values never needs to parse a run: block — it's about six lines of grep, and the lockfiles job that would host it already exists and already triggers on .github/**. Details and a working snippet inline on python-toolchain.env. I'd like that in before merge, because the alternative is a file that documents a convention nothing can hold anyone to, two files away from a job that enforces the same class of invariant properly.

Non-blocking, but please address or reply:

  1. if: always()if: '!cancelled()' on all eleven assert steps. always() also fires on cancellation and after a failed checkout, where the composite action can't resolve and produces a misleading second error.
  2. a2ui_toolkit is a published package (ag-ui-a2ui-toolkit, buildSystem: uv), not an internal one. The known_missing comment should say so, and it wants a follow-up ticket — otherwise "gate committed lockfiles" ships with the released exception permanently waived.
  3. The publish-python-preview.yml hardening deserves its own PR or at least its own line in the title. It's a genuine fork-artifact → pull-requests: write fix and it will not be findable under a uv-pinning changelog entry.
  4. lint-release-workflows.yml: triggers widen to .github/workflows/** + scripts/** while the lint scope stays hand-listed, so the extra runs lint nothing new — and with scripts/** included, fork PRs now reach a github-check reporter that a read-only fork token can't satisfy.
  5. The composite action is linted by neither the shellcheck job nor actionlint. The table's "composite action — clean" is a local result, not a CI one.

Two smaller things, no reply needed:

  • dojo-e2e.yml adds .github/python-toolchain.env to paths:, but that file has no effect on the workflow — the values are inlined — so a no-op edit triggers the full e2e matrix.
  • **/pyproject.toml and **/uv.lock on unit-python-sdk.yml mean editing an examples/ lockfile runs all nine test lanes, which are the lanes that deliberately exclude examples/. Worth narrowing if the matrix cost shows up.

Also worth flagging to whoever merges: every venv cache key and dojo-e2e's restore-keys change at once, so the first run after merge is a cold miss across ~10 caches. Expected, but it'll look like a regression if nobody's told.


Generated by Claude Code

Comment thread .github/python-toolchain.env Outdated
Comment on lines +6 to +10
# It is documentation, not a mechanism. GitHub cannot read a file into a workflow's
# `env:` block, so each Python workflow repeats whichever of these two values it
# actually uses, and NOTHING IN CI CHECKS THAT THEY STILL MATCH — keeping them in step
# is on whoever edits a workflow, and on review. An earlier version of this change
# shipped a checker for it; it cost far more than the drift it prevented and was

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main blocker for me.

The removed checker is cited as the reason no check exists, but it's a strawman for what's actually needed here. Verifying that 17 literals equal two values is not a workflow analyser — it never has to parse run: blocks, resolve expressions, or model job structure. It's a grep, and the lockfiles job already exists and already runs on .github/** changes:

set -euo pipefail
. .github/python-toolchain.env
found=$(grep -rhoE '^  (UV_VERSION|PYTHON_VERSION): "[^"]+"' .github/workflows | sort -u)
expected=$(printf '  PYTHON_VERSION: "%s"\n  UV_VERSION: "%s"' "$PYTHON_VERSION" "$UV_VERSION")
if [ "$found" != "$expected" ]; then
  echo "::error::workflow pins disagree with .github/python-toolchain.env"
  diff <(echo "$expected") <(echo "$found") || true
  exit 1
fi

I ran the equivalent against this branch — 17/17 agree today, so this lands green. That's the moment to add it; it only ever goes red on the drift this file exists to prevent.

There's also a genuine single-source-of-truth option the "GitHub cannot read a file into env:" framing rules out too quickly: a small setup job that reads the file into job outputs. ${{ needs.setup.outputs.uv_version }} is valid in with: and in cache keys, which is where both values are actually consumed — the env: limitation is real but not binding. I'd still take the grep over nine extra needs: edges, but the file shouldn't record the stronger claim.

As it stands the PR applies a weaker standard to its own invariant than the one it imposes on lockfiles two files over, and the reasoning for that asymmetry is recorded in a way that discourages revisiting it.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — you were right, and the strawman diagnosis was correct. scripts/release/verify-python-toolchain-pins.sh, running as the python-toolchain-pins job.

Two differences from your snippet, both from testing it:

It also checks pass 2. Comparing declarations only sees pins that exist. A newly added setup-uv that names no version declares nothing to compare and passes silently — which is precisely the state all 18 invocations were in before this PR. So it also asserts uses: astral-sh/setup-uv@ count equals version: ${{ env.UV_VERSION }} count. Three lines, catches the regression that matters most.

Quote-tolerant, and it reports file:line. UV_VERSION: 0.12.1 and UV_VERSION: "0.12.1" are the same pin; a check that understood one spelling would read the other as absent and pass over the drift. On failure it names each file and line rather than diffing two sorted blobs.

Hosted in lint-release-workflows.yml, not the lockfiles job. This is the one place your comment was off: unit-python-sdk.yml doesn't trigger on .github/**, it lists three specific paths (python-toolchain.env, its own file, .github/actions/**). A pin drifted in zizmor.yml would never have fired it. All nine pin-carrying workflows sit inside lint-release-workflows.yml's lint scope, and it already hosts three sibling guards of exactly this class — hand-maintained list vs. the file it mirrors. It also needs no uv.

Proved it goes red, not just green: drifted literal, unquoted-but-wrong pin, unpinned setup-uv, and renamed pins — 4/4 fail correctly, and a correct-but-single-quoted pin stays green. Both passes refuse to pass vacuously.

Writing it also caught a bug in my own first version: grep -r over .github/workflows with no --include picked up the .yml.bak files my tests left behind and reported 19/19 against a tree with 17 declarations and 18 invocations. A stray .yml.orig from a bad merge would have done the same in CI. Now filtered, and the reason is in the comment so nobody removes it.

On the setup-job point — you're right and the file overstated it. Job outputs are valid in with: and cache keys, which is where both values are consumed, so "GitHub cannot read a file into env:" is true but implies more than it should. The header now records that alternative and why the grep won: nine extra needs: edges, including onto the nine-lane matrix, to remove a duplication six lines already hold in place. If the needs: graph gets rearranged for other reasons, it says to revisit.

The "do not re-add a checker" framing is gone from the file. It was doing what you describe — discouraging revisiting a decision that deserved it.

One thing your comment prompted that's outside this PR: .node-version from #2325 has the identical gap, and CONTRIBUTING says so in as many words ("nothing enforces that automatically yet"). Filed as PNI-280 and cross-linked, since the mechanism now exists.

Comment thread .github/workflows/unit-python-sdk.yml Outdated
Comment on lines +103 to +106
- name: Assert no lockfile was rewritten
# always(): a job that rewrote a lockfile AND then failed its tests would
# otherwise skip this step and report only the test failure, losing the signal.
if: always()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if: always() should be if: '!cancelled()' — here and on the other ten copies (lines 154, 202, 249, 318, 367, 415, 463, 514).

The comment justifies always() by the test-failure case, which !cancelled() covers identically. What always() adds is two cases you don't want:

  • Cancellation. Concurrency cancels supersede runs constantly on this repo; each one now also runs this step.
  • Checkout failure. uses: ./.github/actions/assert-lockfiles-unchanged needs the repo on disk. If Checkout code fails, this step still fires and dies with Can't find 'action.yml' under '.../assert-lockfiles-unchanged' — which lands after the real error and reads like the guard itself is broken. That's precisely the misleading-signal failure mode the action's own header comment is arguing against.

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched all of them to !cancelled() — the cancellation argument is right, and concurrency cancels are frequent enough here that it matters.

The checkout half doesn't work as stated, though. !cancelled() is true when an earlier step failed — it only excludes cancellation. So on a failed Checkout code the job is failure, not cancelled, !cancelled() evaluates true, the step still runs, and it still dies with Can't find 'action.yml' under .../assert-lockfiles-unchanged. Swapping always() for !cancelled() doesn't fix that case at all.

Fixing it needs the outcome gate explicitly, so each checkout now carries an id and the assertion is:

if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}

actionlint validates steps.<id>.outcome references, so the wiring is checked rather than assumed. The step comment now separates the two clauses and says what each one buys, including that the second is not redundant with the first.

Minor count correction: there are nine assert steps, not eleven. Your line list has nine entries so the substance was right. The other two always() in that file are unrelated pre-existing steps — Report google-adk 2.x result (from 177348a, reporting a continue-on-error leg) and one in publish-python-preview.yml. Left alone as out of scope.

Comment on lines +592 to +596
set -euo pipefail
# Packages knowingly without a uv.lock. Adding one here is a decision, not
# a workaround: it means nothing verifies that package's dependency graph.
known_missing=(
"./sdks/python/a2ui_toolkit"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth being explicit that this waiver covers a published package, not an internal one.

sdks/python/a2ui_toolkit is ag-ui-a2ui-toolkit with "buildSystem": "uv" in scripts/release/release.config.json:45 — it ships to PyPI. And relockPythonPackage (scripts/release/prepare-release.ts:358) returns early when there's no uv.lock, so the release path won't grow one either. Net effect: the one package this new gate can't cover is one that users install.

The step comment says "it means nothing verifies that package's dependency graph" but doesn't say the package is released, which is the part that decides whether this is a shrug or a follow-up. Please note it here and open a ticket to uv lock it — a PR titled "gate committed lockfiles" landing with the published exception waived is the kind of thing that stays waived.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted in the comment and filed as PNI-279. Verified your facts first — ag-ui-a2ui-toolkit, "buildSystem": "uv" at release.config.json:45, and relockPythonPackage early-returning at prepare-release.ts:358 all check out, so the release path genuinely will not grow one.

One thing that changes the severity, worth recording: that pyproject.toml declares dependencies = []. uv lock there resolves exactly one package — itself — so the committed lock would be near-empty and there is no dependency graph going unverified today. The real exposure is forward-looking: with no lock, the first dependency anyone adds arrives unlocked and invisible to the step above. That's what the ticket closes, and both facts are now in the comment so the next reader doesn't have to re-derive either.

I considered just locking it here, since at zero dependencies it's nearly free. Decided against it for the reason you applied to the security fix: it changes that package's release behaviour (relockPythonPackage would start relocking it on every bump) and it empties known_missing, leaving the stale_exception branch and "${known_missing[@]}" under set -u with no exercised path. Both fine changes; neither belongs as a rider on a CI-pinning PR. The ticket's acceptance criteria name both so it doesn't get done carelessly.

Also confirmed the residual surface a lockfile wouldn't fix either way: [build-system] requires uv_build>=0.8.0,<0.9, which is floating. In the ticket.

Comment on lines +22 to +26
pull_request:
paths:
- ".github/workflows/prepare-release.yml"
- ".github/workflows/publish-release.yml"
- ".github/workflows/canary.yml"
- ".github/workflows/lint-release-workflows.yml"
- "scripts/release/**"
- ".github/workflows/**"
- ".github/actionlint.yaml"
- "scripts/**"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with widening the triggers while the lint scope stays explicit.

The widening buys nothing on its own. actionlint_flags below is still a hand-listed set of workflows, and shellcheck still globs scripts/release/*.sh. So a PR touching scripts/foo.ts or an unlisted workflow now spins up both jobs to lint exactly the same files as before. Either widen the lint scope to match the triggers, or keep the triggers at the files that actually affect the result plus the six new workflows.

It changes the fork-PR story. With reporter: github-check, reviewdog creates a check run — and on pull_request from a fork the GITHUB_TOKEN is read-only no matter what the new checks: write says, so that call fails. Previously this workflow almost never fired on fork PRs because the paths were five internal release files. With scripts/** in the list, an external contributor touching scripts/ now trips it. Worth either gating the job on github.event.pull_request.head.repo.full_name == github.repository or switching the reporter for the fork case.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed the triggers to match the lint scope, which fixes both halves at once.

paths: is now the ten linted workflows + .github/actions/assert-lockfiles-unchanged/action.yml + .github/actionlint.yaml + .github/python-toolchain.env + scripts/release/** + nx.json. No more runs that lint nothing new, and scripts/** is gone so an external contributor touching scripts/ no longer reaches the github-check reporter.

Chose narrowing over widening the lint scope because widening is the "one-line change plus whatever it turns up" the header already anticipates, and what it turns up is pre-existing noise across every unlinted workflow — which is what the narrow scope was protecting against. The cost is that the file list now lives in three places (two paths: blocks plus actionlint_flags); that's stated in the header, along with why a generated fourth list would be the wrong trade here.

On the fork case: narrowing makes it rare, not impossible. A fork PR editing one of the ten linted workflows still trips the reporter. I left that alone deliberately — it was already true for prepare-release.yml, publish-release.yml and canary.yml before this PR widened anything, so it's pre-existing rather than introduced here. Happy to add the head.repo.full_name == github.repository gate if you'd rather close it properly, but it felt like a separate change from undoing my own widening.

Writing this caught a bug I'd introduced in the fix itself, worth flagging since it's the kind that lints clean: I first put the "not .github/actions/**" note as a # comment inside the actionlint_flags: >- folded block scalar. # isn't a comment there — it's literal text. The flags string parsed to 73 arguments, 63 of them prose fragments being passed to actionlint. actionlint on the workflow was clean because the YAML is valid; only parsing the value exposed it. Comment moved above the key, with a line warning that nothing below it may be a comment, and I now assert the value parses to exactly 10 existing paths.

Comment on lines 52 to 62
- name: Read PR metadata
id: meta
# These three files come from a workflow_run artifact, which on a fork PR is
# produced by the fork's own run — so they are untrusted input reaching a job
# that holds `pull-requests: write`. Validate the shape before it becomes a step
# output: `echo "k=$(cat f)"` exits 0 even when `cat` fails, so a missing file
# silently yielded an empty value, and a value containing a newline would set
# arbitrary extra outputs. A single grouped redirect also satisfies SC2129,
# which reviewdog reports at error level now that this file is in the
# actionlint scope.
run: |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a real security fix and it shouldn't be buried in a uv-pinning PR.

Fork-produced artifact contents were reaching a job holding pull-requests: write; echo "k=$(cat f)" exits 0 when cat fails, and a newline in any of the three values sets arbitrary extra step outputs. The replacement — set -euo pipefail, shape validation, grouped redirect — is right, and I checked the version regex matches what build-python-preview.yml actually produces (0.0.0.dev${git log -1 --format=%ct}).

The problem is discoverability: nobody auditing this repo's fork-PR surface later will find it under chore(ci): pin one uv and CPython version. Please split it into its own PR, or at minimum call it out in the title and description so it shows up in the changelog as a security change rather than as toolchain housekeeping.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept it here, but the discoverability complaint is fully taken: it's now named in the PR title and has its own section in the description, above the fold, labelled as a security change rather than housekeeping.

The reason for not splitting is mechanical. Adding publish-python-preview.yml to the actionlint scope is what surfaced this — SC2129 at error level on the old echo "k=$(cat f)" block. So a split gives two bad options: land the security PR first and this one rebases onto it (a two-PR ordering dependency on a fix you'd want in fast), or drop the file from the actionlint scope here so the PRs are independent, which ships the new lint coverage deliberately incomplete and leaves a one-line re-add for someone to forget.

Naming it in the title gets the changelog outcome you're after without either. If you'd still rather have it separate I'll do it — say which of the two orderings you prefer and I'll cut it.

Thanks for checking the version regex against build-python-preview.yml's actual output rather than just reading it; that's the part I'd most likely have got wrong.

Comment on lines +28 to +34
run: |
set -euo pipefail

# Refuse to pass vacuously. If the pathspec matches nothing the assertion
# below is trivially true, which is the one outcome a guard must never
# silently produce.
tracked=$(git ls-files -- '*uv.lock' '*poetry.lock' | wc -l | tr -d ' ')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verification table lists "shellcheck (scripts/release/*.sh, composite action) — clean". That's true of your machine, but CI lints neither half of this file:

  • the shellcheck job globs scripts/release/*.sh, which doesn't reach .github/actions/**
  • reviewdog's actionlint gets an explicit workflow file list via actionlint_flags, so composite action definitions are out of scope there too

So the file carrying the most new shell in this PR is the one file nothing checks. actionlint does lint composite actions when pointed at them — adding .github/actions/assert-lockfiles-unchanged/action.yml to the actionlint_flags list, or .github/actions/**/*.yml to the shellcheck job, closes it.

The logic itself looks right to me, for what it's worth — I confirmed .gitignore:28 (**/python/**/.venv/) covers every .venv these jobs create, so the git status pathspec won't pick up venv noise, and the vacuous-pass guard is a good call.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, but not the way you suggested — actionlint can't lint composite actions. 1.7.x has no composite-action mode. Pointed at this file it parses it as a workflow:

action.yml:1:1: "jobs" section is missing in workflow [syntax-check]
action.yml:1:1: "on" section is missing in workflow [syntax-check]
action.yml:2:1: unexpected key "description" for "workflow" section [syntax-check]
action.yml:23:1: unexpected key "runs" for "workflow" section [syntax-check]

So adding it to actionlint_flags would have turned the actionlint job red with four bogus errors and linted nothing. I tried it with a deliberately injected SC2086 to check whether the shell got scanned at all — it doesn't.

Your other suggestion is the workable one, and it needs the shell to be in a file first. The check now lives in assert-lockfiles-unchanged.sh and action.yml invokes it via $GITHUB_ACTION_PATH; the shellcheck job globs .github/actions alongside scripts/release. Logic unchanged. 12 files, clean.

Two things that fell out of doing it:

  • The job used shopt -s nullglob + a scripts/release/*.sh array. I first reached for .github/actions/**/*.sh, then found globstar is bash 4 and this repo gets developed on macOS where /bin/bash is 3.2 — the glob silently matches nothing there. mapfile has the same problem. It's find + xargs -0 now, which runs identically in 3.2 and 5, so the job can actually be verified locally rather than only in CI — which was your underlying point about my table.
  • It also exited 0 when the glob matched nothing. A lint job that passes by checking nothing is the same vacuous-pass failure you credited the action for guarding against, so that's now a hard failure with an error message.

You're right that "composite action — clean" in the table was a local result. The table now marks what was verified how, and this file is genuinely in CI's shellcheck scope rather than only on my machine.

Also confirmed your .gitignore:28 finding independently — **/python/**/.venv/ does cover every .venv these jobs create, so the pathspec stays clean.

mme added 4 commits August 6, 2026 16:41
The pin was the one invariant in this change left to convention, and the
reason recorded for that did not survive review: the ~1,400-line workflow
analyser that was built and removed is not what checking this needs.
Comparing 17 literals against two recorded values never has to parse a
`run:` block, resolve an expression, or model job structure.

scripts/release/verify-python-toolchain-pins.sh does it in two passes:

  1. every UV_VERSION / PYTHON_VERSION literal under .github/workflows/
     equals .github/python-toolchain.env, reported as file:line on failure
     and quote-tolerant so an unquoted pin is not read as absent
  2. every `uses: astral-sh/setup-uv@` has a matching
     `version: ${{ env.UV_VERSION }}`, which pass 1 cannot see — a new
     unpinned invocation declares nothing to compare and would pass silently

Both refuse to pass vacuously: a renamed or reindented pin fails loudly
rather than inspecting nothing.

It runs as the python-toolchain-pins job in lint-release-workflows.yml
rather than unit-python-sdk.yml's `lockfiles` job. That job watches three
specific .github paths, not .github/workflows/**, so a pin drifted in
zizmor.yml would never have triggered it. All nine pin-carrying workflows
are inside this workflow's lint scope, and it already hosts three sibling
guards of exactly this class.

Two knock-on fixes in the same file:

  - `paths:` now mirrors the lint scope instead of watching
    .github/workflows/** + scripts/**. The wide triggers spent two runners
    to lint the same hand-listed files as before, and `scripts/**` meant a
    fork PR touching scripts/ reached reviewdog's github-check reporter,
    which a read-only fork token cannot satisfy.
  - shellcheck now covers .github/actions via find rather than a
    scripts/release/*.sh glob, and fails instead of exiting 0 when the
    search matches nothing.

`.github/python-toolchain.env` and eight workflow headers all asserted the
pin was unchecked; every one of those claims is now false, so all are
rewritten. The pins file also over-claimed the `env:`-cannot-read-a-file
limitation — a setup job exposing job outputs would work in both `with:`
and cache keys — so it now records that alternative and why the grep was
preferred over nine extra `needs:` edges.

Verified: 17/17 declarations agree, 18/18 invocations pinned, and the check
goes red on a drifted literal, an unquoted-but-wrong pin, an unpinned
setup-uv, and renamed pins.

Refs PNI-187
Review pointed out that the file carrying the most new shell in this change
is the one file nothing checks, and suggested adding it to actionlint_flags.
That does not work: actionlint 1.7.x has no composite-action mode. Pointed
at an action.yml it parses it as a workflow and reports four bogus
syntax-check errors ("jobs" section is missing, unexpected key "runs", ...),
so adding it there would have turned the actionlint job red rather than
linting anything.

Shell embedded in a composite action is therefore unlintable by either tool
in this repo. Extracting it to assert-lockfiles-unchanged.sh makes it an
ordinary script that the shellcheck job covers — the previous commit widened
that job to .github/actions for this reason. action.yml now invokes it via
$GITHUB_ACTION_PATH; the logic is unchanged.

Verified: 12 files shellchecked clean, including this one, under bash 3.2.

Refs PNI-187
Four review findings on the gate itself.

`if: always()` -> `if: !cancelled() && steps.checkout.outcome == 'success'`
on all nine assert steps. always() also fires on cancellation, and
concurrency cancels supersede runs constantly here.

The checkout half needs the explicit gate, though: !cancelled() is TRUE when
an earlier step failed, including `Checkout code`. Since the assert step is a
local `uses:`, a failed checkout would still run it and die with "Can't find
'action.yml'" — landing after the real error and reading as if the guard were
broken, which is the misleading signal action.yml's header argues against.
!cancelled() alone does not fix that, so each checkout now carries an id and
the assertion gates on its outcome.

The known_missing waiver now says its single entry is a PUBLISHED package:
sdks/python/a2ui_toolkit ships as ag-ui-a2ui-toolkit with
"buildSystem": "uv", and relockPythonPackage early-returns for lock-less
packages, so the release path will not grow one either. It also records that
the package declares `dependencies = []` — so the lock would be near-empty
and nothing is unverified today; the exposure is the FIRST dependency anyone
adds arriving invisible to the gate. Tracked in PNI-279, which writes down
the release-behaviour and empty-array consequences of closing it.

Two trigger fixes:

  - `!examples/**` added after the **/uv.lock and **/pyproject.toml globs.
    Those globs stay — unit-python-sdk.yml:19 documents them as load-bearing
    for the repo-wide lockfiles job — but an examples-only lockfile edit was
    running all nine test lanes, which deliberately exclude examples/.
  - dojo-e2e.yml drops .github/python-toolchain.env from `paths:`. Its values
    are inlined, so editing the record triggered the full e2e matrix on a run
    that used the OLD values: misleading, not merely wasteful. The path stays
    on unit-python-sdk.yml, where it is real.

Refs PNI-187, PNI-279
The merge from main brought in PNI-186's Toolchain section, which documents
`.node-version` and states that nothing enforces it yet. That is now the only
unenforced pin record in the repo, sitting two sections away from one that is
enforced, so the two read as unrelated when they are the same pattern.

Cross-links them and points at PNI-280, which tracks closing the same gap for
Node and pnpm using the script this PR adds. pnpm needs a decision first — it
is pinned inline in 9 of 11 steps and via package.json#packageManager in 2.

Refs PNI-187, PNI-280
@mme mme changed the title chore(ci): pin one uv and CPython version across Python CI, and gate committed lockfiles chore(ci): pin and enforce one uv/CPython version across Python CI, gate committed lockfiles, and harden fork-artifact handling in publish-python-preview Aug 6, 2026
@mme

mme commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

All nine items addressed, pushed as four commits on top of the main merge. Thanks for verifying the mechanics locally instead of taking the table on faith — two of the five inline suggestions turned out not to work as written, and that only surfaced because you'd given specific enough claims to test.

# item outcome
pin invariant unenforced (blocking) verify-python-toolchain-pins.sh + python-toolchain-pins job. Hosted in lint-release-workflows.yml, not lockfiles — that job doesn't trigger on .github/**
1 always()!cancelled() done, plus an explicit checkout gate — !cancelled() doesn't fix the failed-checkout case
2 a2ui_toolkit is published noted, PNI-279 filed
3 split the security fix kept, named in the title + own description section — splitting forces a red lint job or a reverted scope addition
4 lint-release-workflows triggers narrowed to match the lint scope
5 composite action unlinted fixed by extracting the shell — actionlint cannot lint action.yml
dojo-e2e inert path removed
**/uv.lock breadth !examples/** added; the globs themselves stay (load-bearing)
cold cache after merge in the description under For whoever merges

Two places I pushed back rather than complied, both in-thread:

  1. !cancelled() doesn't fix the failed-checkout case. It's true when an earlier step failed — it only excludes cancellation. A failed checkout still runs the step and still dies with Can't find 'action.yml'. Took !cancelled() for the cancellation half, added steps.checkout.outcome == 'success' for the other.
  2. **/uv.lock and **/pyproject.toml are deliberate, and unit-python-sdk.yml:19 says so — the lockfiles job discovers locks repo-wide, so a package outside the hand-listed paths must still trigger it. Narrowing defeats the gate; !examples/** drops exactly the case you flagged.

On the zizmor bot finding (lint-release-workflows.yml:49): it's stale. Alert 365 was raised against 5084f7d6, an earlier push, and the explanatory comment now at lines 46-49 clears it. Ran zizmor v1.29.0 exactly as CI does (--config .github/zizmor.yml --persona=auditor .github/workflows): six undocumented-permissions findings at HEAD, none in this file. The five files that do have them are all pre-existing on main at e8befd8c. Also diffed the whole zizmor surface against the merge-base — 0 new findings across all nine rules.

Two bugs the review process caught in my own fixes, both of which lint clean and are worth knowing about:

  • I put a # comment inside the actionlint_flags: >- folded block scalar. # is literal text there, so the flags string parsed to 73 arguments — 63 of them prose being handed to actionlint. actionlint on the workflow was clean because the YAML is valid; only parsing the value exposed it. There's now an assertion that it parses to exactly 10 existing paths.
  • The pin script's first version used grep -r with no --include and counted the .yml.bak files my own tests left behind, reporting 19/19 against a tree with 17 declarations and 18 invocations. A .yml.orig from a bad merge would do the same in CI.

One thing outside this PR that your blocking argument applies to unchanged: .node-version from #2325 has the identical gap, and CONTRIBUTING states it outright — "nothing enforces that automatically yet". Now that the mechanism exists it's a small extension, so I filed PNI-280 and cross-linked the two sections. pnpm needs a decision first: 9 of 11 steps pin inline, 2 use package.json#packageManager.

Verification table in the description is refreshed against 325907e5, with each row marked by how it was checked. The new rows worth noting: the pin check goes red on 4/4 drift cases (not just green on the happy path), and shellcheck passes under bash 3.2 so the result isn't machine-specific.

Outstanding question for you: item 3. If you still want the security fix split out, tell me which ordering you prefer — land it first and rebase this, or drop publish-python-preview.yml from the actionlint scope here so the two are independent — and I'll cut it.

@mme
mme requested a review from contextablemark August 6, 2026 14:54

@contextablemark contextablemark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The blocking item is genuinely resolved, and you were right on all three points where you pushed back.

I mutation-tested the new check rather than reading it, against 325907e:

mutation result
baseline 17/17 declarations, 18/18 invocations, exit 0
zizmor.yml UV_VERSION0.12.2 correct file:line failure
one version: line dropped 18 invocation(s) but 17 pinned, names zizmor.yml
all version: lines stripped exit 1, no output — see inline
all setup-uv removed exit 1, no output — see inline
cross-file count balance passes green with an unpinned setup-uv live — see inline

Also confirmed: 9 checkout ids against 9 steps.checkout.outcome conditions, 12 files in the shellcheck glob, the python-toolchain-pins paths: covering all nine pin-carrying workflows, and both new scripts clean under bash -n. CI is green across all 56 checks.

Three corrections of mine that you got right, worth recording since two of them would have made things worse:

  • !cancelled() does not cover the failed-checkout case. A failed checkout is failure, not cancelled, so the step would still have run and still died on the missing action.yml. !cancelled() && steps.checkout.outcome == 'success' is the correct fix and I was wrong to conflate the two.
  • Nine assert steps, not eleven. I counted two unrelated pre-existing always() steps into the total; you were right to leave both alone.
  • actionlint 1.7.x cannot lint composite actions. Following my suggestion would have turned the actionlint job red with four bogus syntax-check errors while linting no shell at all. Extracting to a .sh and pointing the shellcheck job at .github/actions is the right shape.

Your own findings in that round are the kind I'd have missed: the # inside the folded block scalar passing 63 prose fragments to actionlint, globstar/mapfile being bash 4 against a repo developed on bash 3.2, the old nullglob branch exiting 0 on an empty match, and dependencies = [] on a2ui_toolkit — that last one genuinely changes the severity of the waiver and I'd have left it overstated.

Two things I'd fix before merge, both one-liners, neither worth another round:

  1. !examples/** matches nothing. There is no top-level examples/ directory — all 13 example lockfiles are nested under integrations/*/python/examples/ and middlewares/a2a-middleware/examples/, and GitHub paths patterns are root-anchored unless they start with **. An examples-only lock edit still runs all nine lanes, which is what the comment and the PR description say it prevents. "!**/examples/**" works, with one scoping consequence noted inline.
  2. The pin verifier goes silent on total regressions. All pins stripped → exit 1 with zero output, because grep -c exits 1 on no match and pipefail + set -e kill the script before the diagnostic. Fails closed, so nothing unsafe, but line 109's vacuous-pass guard is unreachable in exactly the scenario it names.

Two smaller ones, take or leave: the cross-file counting hole is closable with the per-file loop already in the failure branch (~5 lines, no YAML modelling), and the CONTRIBUTING checklist claims the script verifies venv cache keys, which it doesn't — cache keys remain the one convention-only part of this change.

Nothing here blocks. Good change, and the response round was better than the ask.


Generated by Claude Code

Comment on lines +21 to +27
- "**/uv.lock"
- "**/pyproject.toml"
# ...but not the examples/ apps. Their locks are deliberately out of scope for
# the lockfiles job (prep-dojo-everything.js relocks them on purpose), so an
# examples-only lockfile change would spin up all nine test lanes to verify
# nothing. Negation must follow the two globs above to override them.
- "!examples/**"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches nothing — there is no top-level examples/ in this repo.

GitHub paths patterns are anchored at the repository root unless they begin with ** (their own docs give docs/** as "any file in the docs directory at the root of the repository"). All 13 example lockfiles are nested:

integrations/adk-middleware/python/examples          integrations/langroid/python/examples
integrations/ag2/python/examples                     integrations/llama-index/python/examples
integrations/agno/python/examples                    integrations/microsoft-agent-framework/python/examples
integrations/claude-agent-sdk/python/examples        integrations/pydantic-ai/python/examples
integrations/claude-managed-agents/python/examples   integrations/server-starter-all-features/python/examples
integrations/langgraph/python/examples               integrations/server-starter/python/examples
middlewares/a2a-middleware/examples

git ls-files | grep -E '^examples/' returns nothing. So the negation is a no-op, and an examples-only lockfile edit still spins up all nine lanes — the exact behaviour the comment above and the PR description say it prevents. Note this is one of the few claims in the table that couldn't be verified by running something; it reads as true and isn't.

"!**/examples/**" is the working form. One consequence to decide rather than absorb: five packages have integrations/<pkg>/python/** earlier in this list, and last-match-wins means the fixed negation would also stop integrations/langgraph/python/examples/** (and adk-middleware, langroid, claude-managed-agents, claude-agent-sdk) from triggering their own lane. That's probably what you want — the lanes don't test the examples — but it's a wider change than the comment describes, so it should be stated there.


Generated by Claude Code

Comment on lines +106 to +112
invocations=$(grep -c 'uses: astral-sh/setup-uv@' "${GREP_SCOPE[@]}" | awk -F: '{ n += $2 } END { print n + 0 }')
pinned=$(grep -c 'version: ${{ env.UV_VERSION }}' "${GREP_SCOPE[@]}" | awk -F: '{ n += $2 } END { print n + 0 }')

if [ "$invocations" -eq 0 ]; then
echo "::error::No astral-sh/setup-uv invocations found under $WORKFLOWS/ — the search is wrong, not the repo"
exit 1
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass 2 fails silently in the two cases it exists to catch, and the vacuous-pass guard on line 109 is unreachable.

I mutation-tested this rather than reading it. Strip every version: ${{ env.UV_VERSION }} line from .github/workflows/*.yml — 18 invocations, 0 pinned, the headline regression — and you get:

$ bash scripts/release/verify-python-toolchain-pins.sh
$ echo $?
1

No output at all. Same when every uses: astral-sh/setup-uv@ is removed. bash -x shows execution stopping dead at + pinned=0, so neither the -eq 0 guard nor the -ne diagnostic below ever runs.

Cause is on these two lines: grep -c exits 1 when no file matches (confirmed against GNU grep 3.11 — grep -c 'X' -r --include='*.yml' .github/workflows; echo $?1), set -o pipefail propagates that through the | awk, and set -e kills the script at the assignment. Minimal repro:

set -euo pipefail
echo before
n=$(grep -c 'NO_SUCH' -r --include='*.yml' .github/workflows | awk -F: '{n+=$2} END{print n+0}')
echo "after: n=$n"      # never reached; exit 1

It fails closed, so nothing unsafe ships — but a red job with zero explanation is the outcome this script's own header argues against, and line 109's message ("the search is wrong, not the repo") can never print.

invocations=$(... ) || true on both, or { grep -c ... || true; } | awk ..., restores the diagnostics.

Worth noting why the table missed it: all four listed cases are partial mutations, where grep still matches somewhere. I hit it on the total ones. The partial path is genuinely good, for the record — dropping just zizmor.yml's pin gives 18 setup-uv invocation(s) but 17 pinned plus zizmor.yml: 1 invocation(s), 0 pinned.


Generated by Claude Code

Comment on lines +114 to +122
if [ "$invocations" -ne "$pinned" ]; then
echo "::error::${invocations} setup-uv invocation(s) but ${pinned} pinned to \${{ env.UV_VERSION }}"
echo "Every setup-uv must take 'version: \${{ env.UV_VERSION }}'. Unpinned invocations:"
for wf in "$WORKFLOWS"/*.yml "$WORKFLOWS"/*.yaml; do
[ -e "$wf" ] || continue
uses=$(grep -c 'uses: astral-sh/setup-uv@' "$wf" || true)
has=$(grep -c 'version: ${{ env.UV_VERSION }}' "$wf" || true)
[ "$uses" -ne "$has" ] && echo " ${wf}: ${uses} invocation(s), ${has} pinned"
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, but the disclosed hole is narrower than the header claims — and the fix is already sitting in this block.

The counts are compared repo-wide, so a cross-file balance passes green. Demonstrated:

  • drop zizmor.yml's version: ${{ env.UV_VERSION }} line → 1 invocation, 0 pinned
  • add a stray version: ${{ env.UV_VERSION }} to an unrelated uses: in test-release-scripts.yml → 0 invocations, 1 pinned
All 17 pin declaration(s) match .github/python-toolchain.env (uv 0.12.1, CPython 3.12).
All 18 setup-uv invocation(s) resolve their version from it.
exit=0

…with an unpinned setup-uv live in zizmor.yml.

The header attributes this to needing "the YAML structure this check deliberately does not model," and that's the part I'd push back on: the for wf in loop directly below already does the per-file comparison. Promoting it from the failure branch to the primary check closes the cross-file case entirely and leaves only the same-file variant — no structure modelling, and it removes a caveat rather than documenting one. Roughly:

for wf in "$WORKFLOWS"/*.yml "$WORKFLOWS"/*.yaml; do
  [ -e "$wf" ] || continue
  uses=$(grep -c 'uses: astral-sh/setup-uv@' "$wf" || true)
  has=$(grep -c 'version: ${{ env.UV_VERSION }}' "$wf" || true)
  [ "$uses" -eq "$has" ] || { unpinned="${unpinned}  ${wf}: ${uses} invocation(s), ${has} pinned"$'\n'; }
done

The per-file || true here is also why this loop doesn't hit the set -e problem from my other comment — worth keeping that spelling when you fix the totals.


Generated by Claude Code

Comment thread CONTRIBUTING.md
- [ ] End-to-end test spec files added for every supported feature
- [ ] Tests pass locally
- [ ] CI workflow matrix updated in `.github/workflows/dojo-e2e.yml` (entry name matches `agents.ts`)
- [ ] **Python only:** new CI steps use `${{ env.UV_VERSION }}` / `${{ env.PYTHON_VERSION }}` matching `.github/python-toolchain.env`, and any venv cache key carries both versions — verify with `bash scripts/release/verify-python-toolchain-pins.sh` (see Step 7)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overstates what the script covers. verify-python-toolchain-pins.sh never looks at cache keys — pass 1 greps env: declarations, pass 2 counts setup-uv invocations against version: ${{ env.UV_VERSION }}. There's no handling of py${{ env.PYTHON_VERSION }}-uv${{ env.UV_VERSION }} anywhere in it.

So a venv cache key that omits the toolchain segments passes this check green, and the checklist now tells contributors it was verified. Cache keys are the one part of this change that's still convention-only, which is worth saying plainly given the round we just had about that distinction.

Either split the line so only the pin half claims verification, or add a third pass. The third pass is tractable if you want it — count actions/cache steps whose path: matches .venv against keys containing both segments — but scoping it correctly is more work than the other two, and I'd take the honest checklist over a rushed check.

Same applies to the "11 / 11" row in the verification table: true, and locally established, not enforced.


Generated by Claude Code

@mme
mme merged commit c3e8de2 into main Aug 6, 2026
56 checks passed
@mme
mme deleted the markus/pni-187-pin-one-uv-version-across-ag-ui-ci branch August 6, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants